From 7977b07b5c05844deb8bb45f07c760ab11c13223 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 15:56:49 -0700 Subject: [PATCH 001/381] Scaffold frontend-v2: Vite + React 18 + TS strict + Tailwind v4 The v1 frontend is React 17 + Chakra 1 + LESS on webpack, all several major versions behind and expensive to modernize in place. Rather than migrate it, start a parallel frontend with its own dependency tree so the rewrite can land incrementally while the classic board keeps working untouched. This commit is build tooling only: an isolated npm package (nothing added to the root dependency tree), Vite with base /v2/ so the express app can serve the bundle next to v1, a dev proxy for /token and the socket, strict TypeScript, and the design tokens as CSS custom properties under Tailwind v4 (iFixit house style: gray-ramp neutrals, brand blue reserved for action, badge tints, motion as state feedback with a prefers-reduced-motion kill switch). Co-Authored-By: Claude Fable 5 --- frontend-v2/README.md | 46 + frontend-v2/index.html | 12 + frontend-v2/package-lock.json | 3913 +++++++++++++++++++++++++++++++++ frontend-v2/package.json | 29 + frontend-v2/src/styles.css | 275 +++ frontend-v2/tsconfig.json | 19 + frontend-v2/vite.config.ts | 16 + 7 files changed, 4310 insertions(+) create mode 100644 frontend-v2/README.md create mode 100644 frontend-v2/index.html create mode 100644 frontend-v2/package-lock.json create mode 100644 frontend-v2/package.json create mode 100644 frontend-v2/src/styles.css create mode 100644 frontend-v2/tsconfig.json create mode 100644 frontend-v2/vite.config.ts diff --git a/frontend-v2/README.md b/frontend-v2/README.md new file mode 100644 index 00000000..bf1dc853 --- /dev/null +++ b/frontend-v2/README.md @@ -0,0 +1,46 @@ +# Pulldasher frontend v2 + +A reviewer-centric frontend that answers "what should I act on next?" instead +of rendering seven flat columns. It runs beside the classic board: the server +serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol +(`initialize` + `pullChange`), so nothing about the backend or DB changes. + +## Ideas + +- **One pull, one status.** `src/model/status.ts` derives a single mutually + exclusive status per pull (draft > blocked > ci_red > needs_recr > + needs_cr > needs_qa > ready) instead of v1's overlapping column predicates. +- **The re-stamp lane.** Inactive CR signatures (invalidated by a push) are + already on the wire; a pull that was reviewed and fixed but lacks a fresh + stamp is the cheapest review on the board, so it leads. +- **Three lenses, one filter.** For you / People / Teams over the same pool, + with a single saved Scope (repos + people, with GitHub-team presets). +- **Changed since your last look.** A last-seen marker in localStorage; rows + updated after it get a tick, and a banner offers "show only changes". + +## Running it + +```bash +cd frontend-v2 +npm install +npm run dev # against a local backend on :3000 (proxies /token + socket) +npm run dev:dummy # no backend: v1's dummy-pulls.json fixture +npm run build # type-check + bundle to frontend-v2/dist (served at /v2) +npm test # vitest: status-derivation and CI-verdict unit tests +``` + +## Teams config + +Copy `public/teams.example.json` to `public/teams.json` (gitignored) and list +GitHub team slugs with member logins. It powers the Teams lens and the scope +presets; without it those features quietly disappear. Long-term this belongs +in the backend (fetch org teams via the existing Octokit client and ship them +with the socket handshake). + +## Known gaps (backend additions v2 could use) + +- **Comment/review activity isn't on the wire** — cues like "X commented 2h + ago" need the server to include recent comment metadata in `toObject()`. +- **Teams over the handshake** instead of a static file (see above). +- **`changed_files`** is stored in the DB but not sent; the review-weight + chip falls back to size-only until it is. diff --git a/frontend-v2/index.html b/frontend-v2/index.html new file mode 100644 index 00000000..12f7bada --- /dev/null +++ b/frontend-v2/index.html @@ -0,0 +1,12 @@ + + + + + + Pulldasher + + +
+ + + diff --git a/frontend-v2/package-lock.json b/frontend-v2/package-lock.json new file mode 100644 index 00000000..8b5cbafd --- /dev/null +++ b/frontend-v2/package-lock.json @@ -0,0 +1,3913 @@ +{ + "name": "pulldasher-frontend-v2", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pulldasher-frontend-v2", + "version": "0.0.1", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "socket.io-client": "^4.8.3" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.1.0", + "typescript": "^5.7.2", + "vite": "^6.0.0", + "vitest": "^2.1.8" + } + }, + "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", + "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/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "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", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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/@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/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.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/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "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/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "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/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.392", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", + "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", + "dev": true, + "license": "ISC" + }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "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/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/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "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/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "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/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-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "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.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "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/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "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/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "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": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "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": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "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" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "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 + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend-v2/package.json b/frontend-v2/package.json new file mode 100644 index 00000000..4914d16c --- /dev/null +++ b/frontend-v2/package.json @@ -0,0 +1,29 @@ +{ + "name": "pulldasher-frontend-v2", + "private": true, + "version": "0.0.1", + "description": "Reviewer-centric Pulldasher frontend (React 18 + Vite + Tailwind v4)", + "type": "module", + "scripts": { + "dev": "vite", + "dev:dummy": "VITE_DUMMY=1 VITE_DUMMY_USER=danielbeardsley vite", + "build": "tsc --noEmit && vite build", + "test": "vitest run", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "socket.io-client": "^4.8.3" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.1.0", + "typescript": "^5.7.2", + "vite": "^6.0.0", + "vitest": "^2.1.8" + } +} diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css new file mode 100644 index 00000000..b1e8a1d9 --- /dev/null +++ b/frontend-v2/src/styles.css @@ -0,0 +1,275 @@ +@import "tailwindcss"; + +/* ── iFixit house tokens: gray-ramp neutrals, brand blue as the only action + color, status badges as 200-tint bg / 900-step text. The dark board keeps + pulldasher's night-shift habit, derived from the same gray ramp. ── */ +:root { + --canvas: #f9fafb; + --surface: #ffffff; + --muted: #f3f4f6; + --secondary: #e5e7eb; + --border: #d1d5dc; + --ring: #99a1af; + --ink: #030712; + --ink-2: #4b5563; + --ink-3: #6b7280; + --brand: #0071ce; + --brand-50: #e0f1ff; + --brand-100: #cce7ff; + --brand-700: #004a87; + --brand-900: #002b4e; + --badge-green-bg: #bbf7d0; + --badge-green-fg: #14532d; + --badge-red-bg: #fecaca; + --badge-red-fg: #7f1d1d; + --badge-amber-bg: #fde68a; + --badge-amber-fg: #78350f; + --badge-purple-bg: #e9d5ff; + --badge-purple-fg: #581c87; + --badge-brand-bg: var(--brand-100); + --badge-brand-fg: var(--brand-900); + --badge-gray-bg: #e5e7eb; + --badge-gray-fg: #101828; + --ok: #16a34a; + --warn: #d97706; + --bad: #dc2626; + --violet: #9333ea; + --heat0: #d1d5dc; + --heat1: #fe9a00; + --heat2: #e7000b; + --ease-quint: cubic-bezier(0.22, 1, 0.36, 1); + --ease-quart: cubic-bezier(0.25, 1, 0.5, 1); +} + +.dark { + --canvas: #030712; + --surface: #101828; + --muted: #1e2939; + --secondary: #364153; + --border: #364153; + --ring: #6a7282; + --ink: #f9fafb; + --ink-2: #99a1af; + --ink-3: #6a7282; + --brand: #4da3e8; + --brand-50: #0d2b47; + --brand-100: #123a5f; + --brand-700: #8ec6f2; + --brand-900: #cce7ff; + --badge-green-bg: #14532d; + --badge-green-fg: #bbf7d0; + --badge-red-bg: #7f1d1d; + --badge-red-fg: #fecaca; + --badge-amber-bg: #78350f; + --badge-amber-fg: #fde68a; + --badge-purple-bg: #581c87; + --badge-purple-fg: #e9d5ff; + --badge-brand-bg: #123a5f; + --badge-brand-fg: #cce7ff; + --badge-gray-bg: #364153; + --badge-gray-fg: #e5e7eb; + --ok: #4ade80; + --warn: #fbbf24; + --bad: #f87171; + --violet: #c084fc; + --heat0: #364153; +} + +@theme inline { + --color-canvas: var(--canvas); + --color-surface: var(--surface); + --color-muted: var(--muted); + --color-secondary: var(--secondary); + --color-line: var(--border); + --color-ring: var(--ring); + --color-ink: var(--ink); + --color-ink-2: var(--ink-2); + --color-ink-3: var(--ink-3); + --color-brand: var(--brand); + --color-brand-50: var(--brand-50); + --color-brand-700: var(--brand-700); + --color-ok: var(--ok); + --color-warn: var(--warn); + --color-bad: var(--bad); +} + +body { + background: var(--canvas); + color: var(--ink); + font: 400 14px/1.5 ui-sans-serif, system-ui, sans-serif; +} + +a:hover { + color: var(--brand); +} + +/* shared focus ring (house: border-ring + ring/50 at 3px) */ +:is(select, input, button, summary, a):focus-visible { + outline: none; + border-color: var(--ring); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent); +} + +@layer components { + /* status badge: house Badge (200-tint bg, 900 text, 8px radius) */ + .badge { + @apply rounded-lg px-0 py-[3px] text-center text-xs font-medium; + width: 84px; + flex: none; + } + .badge-ready { background: var(--badge-green-bg); color: var(--badge-green-fg); } + .badge-recr { background: var(--badge-brand-bg); color: var(--badge-brand-fg); } + .badge-qa { background: var(--badge-purple-bg); color: var(--badge-purple-fg); } + .badge-cr { background: var(--badge-gray-bg); color: var(--badge-gray-fg); } + .badge-blocked { background: var(--badge-amber-bg); color: var(--badge-amber-fg); } + .badge-red { background: var(--badge-red-bg); color: var(--badge-red-fg); } + .badge-draft { background: none; color: var(--ink-3); border: 1px dashed var(--border); } + + .chip-w { @apply rounded-lg px-1.5 py-0.5 text-center text-[11px] font-medium; min-width: 26px; flex: none; } + .chip-w-XS, .chip-w-S { background: var(--badge-green-bg); color: var(--badge-green-fg); } + .chip-w-M { background: var(--badge-gray-bg); color: var(--badge-gray-fg); } + .chip-w-L { background: var(--badge-amber-bg); color: var(--badge-amber-fg); } + .chip-w-XL { background: var(--badge-red-bg); color: var(--badge-red-fg); } + + .flag-amber { + @apply rounded-lg px-1.5 py-0.5 text-[11px] font-medium; + background: var(--badge-amber-bg); + color: var(--badge-amber-fg); + flex: none; + } + + /* signature pips */ + .pip { width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid var(--ring); } + .pip-full { background: var(--ok); border-color: var(--ok); } + .pip-stale { border-color: var(--warn); position: relative; } + .pip-stale::after { + content: ""; + position: absolute; + left: -2px; + right: -2px; + top: 3px; + border-top: 1.5px solid var(--warn); + transform: rotate(-30deg); + } + + /* age heat bars */ + .heat-bar { width: 4px; height: 10px; border-radius: 2px; background: var(--heat0); } + .heat-warm { background: var(--heat1); } + .heat-hot { background: var(--heat2); } +} + +/* ── Motion: state feedback only, exponential ease-out, one settle entrance ── */ +@keyframes settle { + from { + opacity: 0; + translate: 0 8px; + } +} +main > * { + animation: settle 300ms var(--ease-quint) backwards; +} +main > *:nth-child(2) { animation-delay: 40ms; } +main > *:nth-child(3) { animation-delay: 80ms; } +main > *:nth-child(4) { animation-delay: 120ms; } +main > *:nth-child(n + 5) { animation-delay: 160ms; } + +details[open] > .fold-body { + animation: settle 220ms var(--ease-quint); +} + +@keyframes pop { + from { + opacity: 0; + scale: 0.97; + translate: 0 -4px; + } +} +.popover { + transform-origin: top left; + animation: pop 180ms var(--ease-quint); +} + +@keyframes slide-down { + from { + opacity: 0; + translate: 0 -6px; + } +} +.notice-inner { + animation: slide-down 250ms var(--ease-quint); +} + +/* changed rows: the tick flashes once, then rests */ +@keyframes fresh-flash { + 0% { + background: color-mix(in srgb, var(--brand) 12%, transparent); + } + 100% { + background: transparent; + } +} +.row-fresh { + border-left-color: var(--brand) !important; + animation: fresh-flash 1.4s var(--ease-quart) 300ms backwards; +} + +/* press feedback: house scale-97 */ +.pressable { + transition: + background-color 150ms ease-out, + color 150ms ease-out, + border-color 150ms ease-out, + scale 150ms ease-out; +} +.pressable:active { + scale: 0.97; +} + +/* the live dot breathes: connected and listening */ +@keyframes breathe { + 50% { + opacity: 0.4; + } +} +.conn-live { + animation: breathe 3s ease-in-out infinite; +} + +/* empty-state check draws itself */ +@keyframes draw-check { + from { + stroke-dashoffset: 26; + } + to { + stroke-dashoffset: 0; + } +} +.draw-check { + stroke-dasharray: 26; + animation: draw-check 500ms var(--ease-quint) 200ms backwards; +} + +@keyframes nod { + 50% { + scale: 1.25; + } +} +input[type="checkbox"]:checked { + animation: nod 180ms var(--ease-quint); +} +input[type="checkbox"] { + accent-color: var(--brand); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + .pressable:active { + scale: 1; + } +} diff --git a/frontend-v2/tsconfig.json b/frontend-v2/tsconfig.json new file mode 100644 index 00000000..e0ee5112 --- /dev/null +++ b/frontend-v2/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/frontend-v2/vite.config.ts b/frontend-v2/vite.config.ts new file mode 100644 index 00000000..6b68dc4f --- /dev/null +++ b/frontend-v2/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +// Served by the express app at /v2 (see app.js); the dev server proxies +// /token and the socket back to a locally running pulldasher backend. +export default defineConfig({ + base: '/v2/', + plugins: [react(), tailwindcss()], + server: { + proxy: { + '/token': 'http://localhost:3000', + '/socket.io': { target: 'http://localhost:3000', ws: true }, + }, + }, +}); From a3df9fd5053a458aa8f1516d58b55f75f1e82a96 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 15:57:11 -0700 Subject: [PATCH 002/381] Add wire types and socket layer for frontend-v2 The server protocol (initialize snapshot + per-pull pullChange broadcasts, single-use socket tokens from /token) is shared with v1 and unchanged. types.ts transcribes exactly what Pull.toObject() sends, so the two frontends stay honest against the same payload. The backend interface hides the transport behind four calls (whoami, onPulls, onConnection, refreshPull) with two implementations: the live socket.io client, which re-fetches /token on every reconnect because socket tokens are single-use and expire in ~100s, and a dummy backend that loads v1's frontend/dummy-pulls.json fixture so the whole UI runs with no server, no DB, and no GitHub app. The fixture's pulls are re-dated deterministically because a decade-old snapshot would pin every age-derived signal (heat, starvation, freshness) at maximum. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/backend/dummy.ts | 38 +++++++++++ frontend-v2/src/backend/socket.ts | 80 +++++++++++++++++++++++ frontend-v2/src/types.ts | 104 ++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 frontend-v2/src/backend/dummy.ts create mode 100644 frontend-v2/src/backend/socket.ts create mode 100644 frontend-v2/src/types.ts diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts new file mode 100644 index 00000000..8807e7dd --- /dev/null +++ b/frontend-v2/src/backend/dummy.ts @@ -0,0 +1,38 @@ +import type { InitializePayload, PullData } from '../types'; + +/** + * Dummy mode: run the whole UI without a backend. + * npm run dev:dummy + * Reuses v1's fixture (frontend/dummy-pulls.json) so both frontends stay + * honest against the same wire shape. + */ +export function isDummy(): boolean { + return import.meta.env.VITE_DUMMY === '1'; +} + +export function dummyUser(): string { + return import.meta.env.VITE_DUMMY_USER || 'danielbeardsley'; +} + +export async function loadDummy(): Promise { + const pulls = (await import('../../../frontend/dummy-pulls.json')) + .default as unknown as PullData[]; + return { + repos: [{ name: 'iFixit/ifixit' }], + // The fixture is a decade of frozen pulls; re-date them so age-derived + // signals (heat, starvation, freshness) exercise realistically. + pulls: pulls.map((p, i) => redate(p, i)), + }; +} + +function redate(pull: PullData, i: number): PullData { + const now = Date.now(); + const ageDays = (i * 7919) % 45; // deterministic spread, 0-45 days + const created = new Date(now - ageDays * 86400_000); + const updated = new Date(now - ((i * 104729) % (72 * 3600_000))); + return { + ...pull, + created_at: created.toISOString(), + updated_at: updated.toISOString(), + }; +} diff --git a/frontend-v2/src/backend/socket.ts b/frontend-v2/src/backend/socket.ts new file mode 100644 index 00000000..eec72b75 --- /dev/null +++ b/frontend-v2/src/backend/socket.ts @@ -0,0 +1,80 @@ +import { io, type Socket } from 'socket.io-client'; +import type { InitializePayload, PullData, TokenResponse } from '../types'; +import { isDummy, loadDummy, dummyUser } from './dummy'; + +export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'; + +export interface Backend { + /** Resolves once we know who the viewer is (the /token user). */ + whoami: () => Promise; + /** Streams the initial snapshot, then one pull per change. */ + onPulls: (handler: (payload: InitializePayload | PullData) => void) => void; + onConnection: (handler: (state: ConnectionState) => void) => () => void; + refreshPull: (repo: string, number: number) => void; +} + +function liveBackend(): Backend { + let socket: Socket | null = null; + let token: Promise | null = null; + + const getToken = () => { + token = token ?? fetch('/token').then(r => r.json()); + return token; + }; + + const getSocket = () => { + if (socket) return socket; + socket = io(); + socket.on('connect', () => { + // Socket tokens are single-use and expire quickly: re-fetch on every + // (re)connect rather than reusing the first one. + token = null; + void getToken().then(t => socket!.emit('authenticate', t.socketToken)); + }); + return socket; + }; + + return { + whoami: () => getToken().then(t => t.user), + onPulls(handler) { + const s = getSocket(); + s.on('initialize', (data: InitializePayload) => handler(data)); + s.on('pullChange', (pull: PullData) => handler(pull)); + }, + onConnection(handler) { + const s = getSocket(); + const connected = () => handler('connected'); + const disconnected = () => handler('disconnected'); + const connecting = () => handler('connecting'); + s.on('connect', connected); + s.on('disconnect', disconnected); + s.io.on('reconnect_attempt', connecting); + return () => { + s.off('connect', connected); + s.off('disconnect', disconnected); + s.io.off('reconnect_attempt', connecting); + }; + }, + refreshPull(repo, number) { + getSocket().emit('refresh', repo, number); + }, + }; +} + +function dummyBackend(): Backend { + return { + whoami: () => Promise.resolve(dummyUser()), + onPulls(handler) { + void loadDummy().then(payload => handler(payload)); + }, + onConnection(handler) { + handler('connected'); + return () => {}; + }, + refreshPull() { + // Nothing to refresh against; the dummy data is static. + }, + }; +} + +export const backend: Backend = isDummy() ? dummyBackend() : liveBackend(); diff --git a/frontend-v2/src/types.ts b/frontend-v2/src/types.ts new file mode 100644 index 00000000..1a99be7b --- /dev/null +++ b/frontend-v2/src/types.ts @@ -0,0 +1,104 @@ +/** + * The wire protocol: what the server's Pull.toObject() sends over socket.io. + * Mirrors frontend/src/types.ts (v1) — the server payload is shared between + * both frontends, so changes here must stay compatible with v1. + */ + +export type DateString = string; + +export type PullState = 'open' | 'closed'; + +export type StatusState = 'error' | 'pending' | 'success' | 'failure'; + +export type SignatureType = 'CR' | 'QA' | 'dev_block' | 'deploy_block'; + +export interface RepoSpec { + name: string; + requiredStatuses?: string[]; + ignoredStatuses?: string[]; + hideByDefault?: boolean; +} + +export interface Label { + title: string; + number: number; + repo: string; + user: string; + created_at: DateString; +} + +export interface Signature { + data: { + repo: string; + number: number; + user: { id: number; login: string }; + type: SignatureType; + created_at: DateString; + active: number; + comment_id: number; + }; +} + +export interface CommitStatus { + data: { + sha: string; + target_url: string | null; + description: string; + state: StatusState; + context: string; + started_at: number | null; + completed_at: number | null; + }; +} + +export interface PullData { + repo: string; + number: number; + state: PullState; + title: string; + body: string; + draft: boolean; + created_at: DateString; + updated_at: DateString; + closed_at: DateString | null; + merged_at: DateString | null; + mergeable: boolean | null; + difficulty: number | null; + additions: number | null; + deletions: number | null; + changed_files?: number | null; + milestone: { title: string | null; due_on: string | null }; + head: { ref: string; sha: string; repo: { owner: { login: string } } }; + base: { ref: string }; + user: { login: string }; + cr_req: number; + qa_req: number; + status: { + cr_req: number; + qa_req: number; + allCR: Signature[]; + allQA: Signature[]; + dev_block: Signature[]; + deploy_block: Signature[]; + commit_statuses: CommitStatus[]; + }; + labels: Label[]; + participants: string[]; +} + +export interface InitializePayload { + repos: RepoSpec[]; + pulls: PullData[]; +} + +export interface TokenResponse { + socketToken: string; + user: string; + title: string; +} + +/** GitHub team → members, for the Teams lens (served as static config). */ +export interface Team { + team: string; + members: string[]; +} From c78d4fc86e741fe9611523e66d76dd3cf305f6d3 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 15:57:11 -0700 Subject: [PATCH 003/381] Derive one mutually exclusive status per pull, with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 computes column membership with six independent boolean predicates (isCiBlocked, isReady, needs-CR, needs-QA, ...), so one pull can sit in several columns at once and no single field answers "what state is this pull in?". That made the board hard to compact and per-user views impossible to rank. derive() collapses the raw signature/status arrays into one status by precedence: draft > blocked > ci_red > needs_recr > needs_cr > needs_qa > ready. The interesting lane is needs_recr: the server already ships CR signatures a push invalidated (active=0), which identifies "reviewed once, author pushed the fix, awaiting a fresh stamp" — the cheapest review on the board, and something the v1 UI never surfaced. recrBy names who owes the stamp; the head-CI start time proxies when the fix landed. Also here because the server leaves them to the client: the CI verdict reduction (requiredStatuses/ignoredStatuses per repo, statuses off the current head sha ignored), starvation (age >= 7d with no CR ever), and a size-and-sprawl review-weight prior the UI sorts by. Covered by vitest unit tests since every lane on the board hangs off this file. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/model/status.test.ts | 196 +++++++++++++++++++++++++++ frontend-v2/src/model/status.ts | 181 +++++++++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 frontend-v2/src/model/status.test.ts create mode 100644 frontend-v2/src/model/status.ts diff --git a/frontend-v2/src/model/status.test.ts b/frontend-v2/src/model/status.test.ts new file mode 100644 index 00000000..8850dfdb --- /dev/null +++ b/frontend-v2/src/model/status.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import type { PullData, Signature, SignatureType } from '../types'; +import { ciVerdict, derive, reviewWeight } from './status'; + +const NOW = 1_800_000_000; + +function sig( + type: SignatureType, + login: string, + active: boolean, + at = '2026-01-01T00:00:00Z' +): Signature { + return { + data: { + repo: 'iFixit/ifixit', + number: 1, + user: { id: 1, login }, + type, + created_at: at, + active: active ? 1 : 0, + comment_id: 1, + }, + }; +} + +function pull(overrides: Partial = {}): PullData { + return { + repo: 'iFixit/ifixit', + number: 1, + state: 'open', + title: 'Test pull', + body: '', + draft: false, + created_at: new Date((NOW - 86400) * 1000).toISOString(), + updated_at: new Date((NOW - 86400) * 1000).toISOString(), + closed_at: null, + merged_at: null, + mergeable: true, + difficulty: null, + additions: 10, + deletions: 5, + changed_files: 2, + milestone: { title: null, due_on: null }, + head: { ref: 'branch', sha: 'abc', repo: { owner: { login: 'iFixit' } } }, + base: { ref: 'master' }, + user: { login: 'author' }, + cr_req: 1, + qa_req: 1, + status: { + cr_req: 1, + qa_req: 1, + allCR: [], + allQA: [], + dev_block: [], + deploy_block: [], + commit_statuses: [], + }, + labels: [], + participants: [], + ...overrides, + }; +} + +function withStatus( + overrides: Partial, + base: Partial = {} +): PullData { + const p = pull(base); + return { ...p, status: { ...p.status, ...overrides } }; +} + +describe('status derivation precedence', () => { + it('draft wins over everything', () => { + const p = withStatus({ dev_block: [sig('dev_block', 'x', true)] }, { draft: true }); + expect(derive(p, undefined, NOW).status).toBe('draft'); + }); + + it('blocked wins over ci_red', () => { + const p = withStatus({ + deploy_block: [sig('deploy_block', 'x', true)], + commit_statuses: [ci('failure')], + }); + expect(derive(p, undefined, NOW).status).toBe('blocked'); + }); + + it('failing CI beats the review lanes', () => { + const p = withStatus({ commit_statuses: [ci('failure')] }); + expect(derive(p, undefined, NOW).status).toBe('ci_red'); + }); + + it('an invalidated CR makes needs_recr, and names who owes the re-stamp', () => { + const p = withStatus({ allCR: [sig('CR', 'reviewer', false)] }); + const d = derive(p, undefined, NOW); + expect(d.status).toBe('needs_recr'); + expect(d.recrBy).toEqual(['reviewer']); + }); + + it('a re-stamp by the same user returns the pull to the normal flow', () => { + const p = withStatus({ + allCR: [sig('CR', 'reviewer', false), sig('CR', 'reviewer', true)], + }); + const d = derive(p, undefined, NOW); + expect(d.status).toBe('needs_qa'); + expect(d.recrBy).toEqual([]); + }); + + it("the author's own stale stamp never creates a re-stamp lane entry", () => { + const p = withStatus({ allCR: [sig('CR', 'author', false)] }); + expect(derive(p, undefined, NOW).status).toBe('needs_cr'); + }); + + it('CR done, QA missing → needs_qa; both → ready', () => { + const crOnly = withStatus({ allCR: [sig('CR', 'r', true)] }); + expect(derive(crOnly, undefined, NOW).status).toBe('needs_qa'); + const both = withStatus({ + allCR: [sig('CR', 'r', true)], + allQA: [sig('QA', 'q', true)], + }); + expect(derive(both, undefined, NOW).status).toBe('ready'); + }); + + it('honors cr_req > 1', () => { + const p = withStatus({ + cr_req: 2, + allCR: [sig('CR', 'r', true)], + }); + expect(derive(p, undefined, NOW).status).toBe('needs_cr'); + }); +}); + +function ci(state: 'success' | 'failure' | 'pending' | 'error', context = 'build', sha = 'abc') { + return { + data: { + sha, + target_url: null, + description: '', + state, + context, + started_at: NOW - 3600, + completed_at: null, + }, + } as const; +} + +describe('ciVerdict', () => { + it('no statuses, nothing required → none', () => { + expect(ciVerdict(pull())).toBe('none'); + }); + + it("ignored statuses don't fail the build", () => { + const p = withStatus({ + commit_statuses: [ci('success'), ci('failure', 'stress-test')], + }); + expect(ciVerdict(p, { name: 'iFixit/ifixit', ignoredStatuses: ['stress-test'] })).toBe( + 'success' + ); + expect(ciVerdict(p, { name: 'iFixit/ifixit' })).toBe('failing'); + }); + + it("a configured-required status that hasn't reported is pending", () => { + const p = withStatus({ commit_statuses: [ci('success', 'build')] }); + expect( + ciVerdict(p, { + name: 'iFixit/ifixit', + requiredStatuses: ['build', 'tests'], + }) + ).toBe('pending'); + }); + + it('only statuses on the current head count when any exist', () => { + const p = withStatus({ + commit_statuses: [ci('failure', 'build', 'old-sha'), ci('success')], + }); + expect(ciVerdict(p, { name: 'iFixit/ifixit' })).toBe('success'); + }); +}); + +describe('starvation and weight', () => { + it('an old un-CRed pull is starved; a reviewed one never is', () => { + const old = new Date((NOW - 10 * 86400) * 1000).toISOString(); + const p = pull({ created_at: old }); + const d = derive(p, undefined, NOW); + expect(d.starved).toBe(true); + expect(d.starveScore).toBeGreaterThan(0); + + const reviewed = withStatus({ allCR: [sig('CR', 'r', false)] }, { created_at: old }); + expect(derive(reviewed, undefined, NOW).starved).toBe(false); + }); + + it('weight scales with size and sprawl', () => { + expect(reviewWeight(pull({ additions: 5, deletions: 0 }))).toBe('XS'); + expect(reviewWeight(pull({ additions: 400, deletions: 100 }))).toBe('M'); + expect(reviewWeight(pull({ additions: 400, deletions: 100, changed_files: 40 }))).toBe('L'); + expect(reviewWeight(pull({ additions: 5000, deletions: 0 }))).toBe('XL'); + }); +}); diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts new file mode 100644 index 00000000..d4bd4a8b --- /dev/null +++ b/frontend-v2/src/model/status.ts @@ -0,0 +1,181 @@ +import type { CommitStatus, PullData, RepoSpec, Signature } from '../types'; + +/** + * One pull, one status. Mutually exclusive by precedence — the fix for v1's + * six overlapping column predicates (a pull could sit in CR and QA at once). + * Precedence mirrors the what-to-review skill's derivation: + * draft > blocked > ci_red > needs_recr > needs_cr > needs_qa > ready + */ +export type Status = + | 'draft' + | 'blocked' + | 'ci_red' + | 'needs_recr' + | 'needs_cr' + | 'needs_qa' + | 'ready'; + +export const STATUS_ORDER: Status[] = [ + 'ready', + 'needs_recr', + 'needs_qa', + 'needs_cr', + 'blocked', + 'ci_red', + 'draft', +]; + +export type CiVerdict = 'success' | 'pending' | 'failing' | 'none'; + +export interface DerivedPull { + data: PullData; + status: Status; + ci: CiVerdict; + /** users whose CR stamp counts on the current head */ + crBy: string[]; + qaBy: string[]; + crHave: number; + qaHave: number; + /** users whose CR a later push invalidated and who haven't re-stamped */ + recrBy: string[]; + /** epoch secs of the invalidating push (head CI start proxy); null if unknown */ + headPushedAt: number | null; + ageDays: number; + /** still waiting on its first CR after STARVE_DAYS */ + starved: boolean; + starveScore: number; + weight: Weight; +} + +export type Weight = 'XS' | 'S' | 'M' | 'L' | 'XL'; +const WEIGHT_RANK: Record = { XS: 0, S: 1, M: 2, L: 3, XL: 4 }; + +export const STARVE_DAYS = 7; + +const activeUsers = (sigs: Signature[]) => + unique(sigs.filter(s => s.data.active).map(s => s.data.user.login)); + +function unique(xs: T[]): T[] { + return [...new Set(xs)]; +} + +/** + * CI verdict, computed client-side (the server ships raw statuses plus the + * per-repo required/ignored lists and leaves the reduction to the frontend). + * Required = repoSpec.requiredStatuses when configured, else every reported + * status minus repoSpec.ignoredStatuses. + */ +export function ciVerdict(pull: PullData, spec?: RepoSpec): CiVerdict { + const onHead = pull.status.commit_statuses.filter(s => s.data.sha === pull.head.sha); + const statuses = onHead.length ? onHead : pull.status.commit_statuses; + if (!statuses.length && !spec?.requiredStatuses?.length) return 'none'; + + let required: CommitStatus[]; + if (spec?.requiredStatuses?.length) { + required = statuses.filter(s => spec.requiredStatuses!.includes(s.data.context)); + // a configured-required status that hasn't reported yet counts as pending + if (required.length < spec.requiredStatuses.length) { + const reported = new Set(required.map(s => s.data.context)); + if (spec.requiredStatuses.some(c => !reported.has(c))) { + if (required.some(s => isFailing(s))) return 'failing'; + return 'pending'; + } + } + } else { + const ignored = new Set(spec?.ignoredStatuses ?? []); + required = statuses.filter(s => !ignored.has(s.data.context)); + } + if (!required.length) return 'none'; + if (required.some(isFailing)) return 'failing'; + if (required.some(s => s.data.state === 'pending')) return 'pending'; + return 'success'; +} + +const isFailing = (s: CommitStatus) => s.data.state === 'failure' || s.data.state === 'error'; + +/** When did the author push the current head? Head-CI-start is the proxy. */ +export function headPushedAt(pull: PullData): number | null { + const starts = pull.status.commit_statuses + .filter(s => s.data.sha === pull.head.sha) + .map(s => s.data.started_at) + .filter((t): t is number => t != null); + return starts.length ? Math.min(...starts) : null; +} + +export function derive( + pull: PullData, + spec: RepoSpec | undefined, + now: number = Date.now() / 1000 +): DerivedPull { + const st = pull.status; + const crBy = activeUsers(st.allCR); + const qaBy = activeUsers(st.allQA); + const crHave = crBy.length; + const qaHave = qaBy.length; + const ci = ciVerdict(pull, spec); + + const staleCr = unique( + st.allCR + .filter(s => !s.data.active) + .map(s => s.data.user.login) + .filter(u => !crBy.includes(u) && u !== pull.user.login) + ); + + const blocked = st.dev_block.length > 0 || st.deploy_block.length > 0; + const crDone = crHave >= st.cr_req; + const qaDone = qaHave >= st.qa_req; + + let status: Status; + if (pull.draft) status = 'draft'; + else if (blocked) status = 'blocked'; + else if (ci === 'failing') status = 'ci_red'; + else if (!crDone && staleCr.length) status = 'needs_recr'; + else if (!crDone) status = 'needs_cr'; + else if (!qaDone) status = 'needs_qa'; + else status = 'ready'; + + const created = Date.parse(pull.created_at) / 1000; + const ageDays = Math.max(0, Math.floor((now - created) / 86400)); + const size = (pull.additions ?? 0) + (pull.deletions ?? 0); + const starved = status === 'needs_cr' && crHave === 0 && ageDays >= STARVE_DAYS; + + return { + data: pull, + status, + ci, + crBy, + qaBy, + crHave, + qaHave, + recrBy: staleCr, + headPushedAt: headPushedAt(pull), + ageDays, + starved, + starveScore: starved ? ageDays * Math.max(size, 1) : 0, + weight: reviewWeight(pull), + }; +} + +/** + * Expected review effort from size and sprawl. A cheap prior, not a verdict: + * the UI sorts by it and shows it as a chip; humans override by reading. + */ +export function reviewWeight(pull: PullData): Weight { + const size = (pull.additions ?? 0) + (pull.deletions ?? 0); + const files = pull.changed_files ?? 0; + let i = size < 50 ? 0 : size < 150 ? 1 : size < 600 ? 2 : size < 1500 ? 3 : 4; + if (files > 15 && i < 4) i++; + return (['XS', 'S', 'M', 'L', 'XL'] as const)[i]; +} + +export const weightRank = (w: Weight) => WEIGHT_RANK[w]; + +/** + * The author pushed or the pull changed in the last 30 minutes with nobody + * else active since: probably still iterating. The UI demotes (dims and + * sinks), never hides. + */ +export function isIterating(pull: PullData, now: number = Date.now() / 1000) { + const updated = Date.parse(pull.updated_at) / 1000; + return now - updated < 30 * 60; +} From d79f11fa359528535bb77ba4cc983615e58c5b46 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 15:57:32 -0700 Subject: [PATCH 004/381] Add the pull store, saved scope, and formatting helpers One store instead of v1's three hand-rolled systems (socket state, named-filter registry, per-column sorts glued through Context). Raw pulls live in a Map keyed by repo#number; each publish re-derives statuses against the repo specs and hands React a sorted snapshot via useSyncExternalStore. Publishes are throttled to 250ms because a server-side bulk refresh emits hundreds of pullChange events in a burst and should cost one render, not hundreds. Two small persistence pieces: - Scope (repos + people) is the one saved customization, in localStorage. A full selection is stored as an empty list on purpose: new repos and new teammates must never be silently excluded by a stale saved list. - A last-seen marker captured at page load powers "changed since your last look": rows updated after it are marked fresh, and the marker advances for next time. Teams config loads from an optional static file (public/teams.json); absent file just disables the team features. Long-term this belongs in the backend via its existing Octokit client. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/format.ts | 22 +++++++++ frontend-v2/src/prefs.ts | 62 ++++++++++++++++++++++++++ frontend-v2/src/store.ts | 93 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 frontend-v2/src/format.ts create mode 100644 frontend-v2/src/prefs.ts create mode 100644 frontend-v2/src/store.ts diff --git a/frontend-v2/src/format.ts b/frontend-v2/src/format.ts new file mode 100644 index 00000000..20cb5510 --- /dev/null +++ b/frontend-v2/src/format.ts @@ -0,0 +1,22 @@ +/** "34m" / "5h" / "3d" — matches the terseness of the board rows. */ +export function ago(epochSecs: number, now: number = Date.now() / 1000) { + const s = Math.max(0, now - epochSecs); + if (s < 3600) return `${Math.round(s / 60)}m`; + if (s < 48 * 3600) return `${Math.round(s / 3600)}h`; + return `${Math.round(s / 86400)}d`; +} + +/** Deterministic avatar hue per login (no external images: CSP-safe). */ +export function loginHue(login: string) { + let h = 0; + for (const c of login) h = (h * 31 + c.charCodeAt(0)) % 360; + return h; +} + +export function githubUrl(repo: string, number: number) { + return `https://github.com/${repo}/pull/${number}`; +} + +export function shortRepo(repo: string) { + return repo.replace(/^iFixit\//, ''); +} diff --git a/frontend-v2/src/prefs.ts b/frontend-v2/src/prefs.ts new file mode 100644 index 00000000..e4c9b84e --- /dev/null +++ b/frontend-v2/src/prefs.ts @@ -0,0 +1,62 @@ +import { useCallback, useSyncExternalStore } from 'react'; +import type { Team } from './types'; + +/** + * Scope is the one saved customization: which repos and people are "my + * board". Empty arrays mean everything — a full selection is stored as + * empty so new repos and new teammates are never silently excluded by a + * stale saved list. + */ +export interface Scope { + repos: string[]; + authors: string[]; +} + +const SCOPE_KEY = 'pd2.scope'; + +function loadScope(): Scope { + try { + return { + repos: [], + authors: [], + ...(JSON.parse(localStorage.getItem(SCOPE_KEY) ?? '{}') as object), + }; + } catch { + return { repos: [], authors: [] }; + } +} + +let scope = loadScope(); +const listeners = new Set<() => void>(); + +export function useScope(): [Scope, (next: Scope) => void] { + const value = useSyncExternalStore( + fn => { + listeners.add(fn); + return () => listeners.delete(fn); + }, + () => scope + ); + const setScope = useCallback((next: Scope) => { + scope = next; + localStorage.setItem(SCOPE_KEY, JSON.stringify(next)); + for (const fn of listeners) fn(); + }, []); + return [value, setScope]; +} + +/** + * Team → members config for the Teams lens and the scope presets. Served as + * a static file next to the app (see teams.example.json); absent file means + * no team features, everything else still works. + */ +export async function loadTeams(): Promise { + try { + const res = await fetch(`${import.meta.env.BASE_URL}teams.json`); + if (!res.ok) return []; + const teams = (await res.json()) as Team[]; + return Array.isArray(teams) ? teams : []; + } catch { + return []; + } +} diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts new file mode 100644 index 00000000..af463161 --- /dev/null +++ b/frontend-v2/src/store.ts @@ -0,0 +1,93 @@ +import { useSyncExternalStore } from 'react'; +import { backend, type ConnectionState } from './backend/socket'; +import { derive, type DerivedPull } from './model/status'; +import type { PullData, RepoSpec } from './types'; + +/** + * The one store: raw pulls keyed by repo#number, re-derived and re-published + * as a sorted snapshot whenever the socket delivers. Throttled so a burst of + * pullChange events (bulk refresh on the server) renders once, not N times. + */ + +export interface Snapshot { + pulls: DerivedPull[]; + repoSpecs: RepoSpec[]; + me: string; + connection: ConnectionState; + /** epoch secs of the last-seen marker captured at page load */ + lastSeen: number; +} + +const LAST_SEEN_KEY = 'pd2.lastSeen'; + +const raw = new Map(); +let repoSpecs: RepoSpec[] = []; +let me = ''; +let connection: ConnectionState = 'connecting'; +const listeners = new Set<() => void>(); + +// Captured once per page load: everything updated after this shows as fresh. +const lastSeen = Number(localStorage.getItem(LAST_SEEN_KEY)) || Date.now() / 1000 - 6 * 3600; +localStorage.setItem(LAST_SEEN_KEY, String(Date.now() / 1000)); + +let snapshot: Snapshot = { pulls: [], repoSpecs, me, connection, lastSeen }; + +function publish() { + const specByName = new Map(repoSpecs.map(s => [s.name, s])); + snapshot = { + pulls: [...raw.values()] + .filter(p => p.state === 'open') + .map(p => derive(p, specByName.get(p.repo))), + repoSpecs, + me, + connection, + lastSeen, + }; + for (const fn of listeners) fn(); +} + +let scheduled = false; +function schedulePublish() { + if (scheduled) return; + scheduled = true; + setTimeout(() => { + scheduled = false; + publish(); + }, 250); +} + +let started = false; +function start() { + if (started) return; + started = true; + void backend.whoami().then(user => { + me = user; + schedulePublish(); + }); + backend.onPulls(payload => { + if ('pulls' in payload) { + repoSpecs = payload.repos; + for (const p of payload.pulls) raw.set(`${p.repo}#${p.number}`, p); + } else { + raw.set(`${payload.repo}#${payload.number}`, payload); + } + schedulePublish(); + }); + backend.onConnection(state => { + connection = state; + schedulePublish(); + }); +} + +export function usePulldasher(): Snapshot { + start(); + return useSyncExternalStore( + fn => { + listeners.add(fn); + return () => listeners.delete(fn); + }, + () => snapshot + ); +} + +export const refreshPull = backend.refreshPull; From a6201031b6573d87809409840e1c60e2950e70af Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 15:57:33 -0700 Subject: [PATCH 005/381] Add the v2 component vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row is the unit of density: one PR per ~36px line (v1 spends ~90px per card), which is what lets 70-90 open CRs read on two screens. Each row carries only what varies in its lane — the status badge drops out when the lane already says it, CR pips hide in QA lanes — plus the shared signals: review-weight chip, stale-stamp pip (struck through), age heat bars, +adds/-dels, and an 'iterating' flag that dims rows whose author pushed in the last 30 minutes (demoted, never hidden). Lane and Fold split the board into "rows you should look at now" and "counts that stay visible, rows on demand" (native details/summary). ScopeControl is the single filter: one popover with GitHub-team presets, people, and repos, committing a full selection as empty so saved scopes don't rot. Avatars are initials on a deterministic per-login hue rather than GitHub images, so the page makes no third-party requests. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Lane.tsx | 116 +++++++++++++++++++ frontend-v2/src/components/Row.tsx | 94 +++++++++++++++ frontend-v2/src/components/Scope.tsx | 167 +++++++++++++++++++++++++++ frontend-v2/src/components/bits.tsx | 140 ++++++++++++++++++++++ 4 files changed, 517 insertions(+) create mode 100644 frontend-v2/src/components/Lane.tsx create mode 100644 frontend-v2/src/components/Row.tsx create mode 100644 frontend-v2/src/components/Scope.tsx create mode 100644 frontend-v2/src/components/bits.tsx diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx new file mode 100644 index 00000000..a2b3ab39 --- /dev/null +++ b/frontend-v2/src/components/Lane.tsx @@ -0,0 +1,116 @@ +import { useState, type ReactNode } from 'react'; +import type { DerivedPull } from '../model/status'; +import { Row, type RowOptions } from './Row'; + +export function Rows({ children }: { children: ReactNode }) { + return ( +
{children}
+ ); +} + +export function Lane({ + title, + sub, + pulls, + cap = 10, + opts, + children, +}: { + title: string; + sub: string; + pulls: DerivedPull[]; + cap?: number; + opts: RowOptions; + /** extra rows rendered inside the container, before the more-line */ + children?: ReactNode; +}) { + const [expanded, setExpanded] = useState(false); + if (!pulls.length && !children) return null; + const shown = expanded ? pulls : pulls.slice(0, cap); + const more = pulls.length - shown.length; + return ( +
+
+ {title} + {sub} + + {pulls.length} +
+ + {children} + {shown.map(p => ( + + ))} + {more > 0 && ( + + )} + +
+ ); +} + +export function DividerLine({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + +/** One folded line in "the rest of the board": count + hint, rows on demand. */ +export function Fold({ + dot, + count, + label, + hint, + children, +}: { + dot: string; + count: number; + label: string; + hint: string; + children: ReactNode; +}) { + if (!count) return null; + return ( +
+ + + ▸ + + + {count} {label} + {hint} + +
{children}
+
+ ); +} + +export function RestGroup({ + title, + sub, + children, +}: { + title?: string; + sub?: string; + children: ReactNode; +}) { + return ( +
+ {title && ( +
+ {title} + {sub} +
+ )} +
{children}
+
+ ); +} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx new file mode 100644 index 00000000..585464db --- /dev/null +++ b/frontend-v2/src/components/Row.tsx @@ -0,0 +1,94 @@ +import type { DerivedPull } from '../model/status'; +import { isIterating } from '../model/status'; +import { ago, githubUrl, shortRepo } from '../format'; +import { Avatar, Heat, Pips, StatusBadge, WeightChip } from './bits'; + +export interface RowOptions { + /** hide the status badge when the lane already says it */ + badge?: boolean; + pips?: 'cr' | 'qa' | 'both' | 'none'; + /** show the waiting-Nd flag on starved pulls */ + aging?: boolean; + /** skip the cooldown dimming (own PRs, folded sections) */ + noDim?: boolean; + me: string; + lastSeen: number; + onPerson?: (login: string) => void; +} + +function cue(p: DerivedPull, me: string): string | null { + const d = p.data; + if (p.status === 'needs_recr' && p.recrBy.length) { + const who = p.recrBy.includes(me) ? 'you' : p.recrBy.join(', '); + const wait = p.headPushedAt ? ` · fix waiting ${ago(p.headPushedAt)}` : ''; + return `CR’d by ${who}${wait}`; + } + if (p.status === 'ready') + return `CR✓ QA✓ green: ${d.user.login === me ? 'your' : `${d.user.login}’s`} merge button`; + if (p.status === 'needs_qa') return 'CR✓, author drives QA'; + if (p.status === 'blocked') { + const block = d.status.dev_block[0] ?? d.status.deploy_block[0]; + return block ? `held by ${block.data.user.login}` : null; + } + return null; +} + +export function Row({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { + const d = pull.data; + const iterating = isIterating(d); + const fresh = Date.parse(d.updated_at) / 1000 > opts.lastSeen; + const pips = opts.pips ?? 'both'; + const showWeight = ['needs_cr', 'needs_recr'].includes(pull.status); + const staleCr = pull.recrBy.length > 0; + const line = cue(pull, opts.me); + + return ( +
+ {opts.badge === false ? null : } + + + + {shortRepo(d.repo)}#{d.number} + + + {d.title} + + + {iterating && ( + + iterating + + )} + {opts.aging && pull.starved && waiting {pull.ageDays}d} + {line && ( + + {line} + + )} + + {showWeight && } + {(pips === 'cr' || pips === 'both') && ( + + )} + {(pips === 'qa' || pips === 'both') && ( + + )} + + +{d.additions ?? '?'}{' '} + −{d.deletions ?? '?'} + + + +
+ ); +} diff --git a/frontend-v2/src/components/Scope.tsx b/frontend-v2/src/components/Scope.tsx new file mode 100644 index 00000000..317a7b5b --- /dev/null +++ b/frontend-v2/src/components/Scope.tsx @@ -0,0 +1,167 @@ +import { useEffect, useRef, useState } from 'react'; +import type { DerivedPull } from '../model/status'; +import type { Team } from '../types'; +import { shortRepo } from '../format'; +import { useScope, type Scope } from '../prefs'; + +/** + * The one filter control: a popover with team presets, people, and repos. + * A full selection commits as empty ("everything") so new repos and new + * teammates never get silently excluded by a stale saved list. + */ +export function ScopeControl({ pulls, teams }: { pulls: DerivedPull[]; teams: Team[] }) { + const [scope, setScope] = useScope(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const close = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('click', close); + return () => document.removeEventListener('click', close); + }, [open]); + + const repoCounts = new Map(); + const authorCounts = new Map(); + for (const p of pulls) { + repoCounts.set(p.data.repo, (repoCounts.get(p.data.repo) ?? 0) + 1); + authorCounts.set(p.data.user.login, (authorCounts.get(p.data.user.login) ?? 0) + 1); + } + const repos = [...repoCounts.entries()].sort((a, b) => b[1] - a[1]); + const authors = [...authorCounts.entries()].sort((a, b) => b[1] - a[1]); + + const scopedCount = scope.repos.length + scope.authors.length; + const label = scopedCount + ? `Scope: ${[ + scope.repos.length && `${scope.repos.length} repos`, + scope.authors.length && `${scope.authors.length} people`, + ] + .filter(Boolean) + .join(' · ')}` + : 'Scope: everything'; + + const commit = (key: keyof Scope, next: string[], all: string[]) => + setScope({ ...scope, [key]: next.length === all.length ? [] : next }); + + const toggle = (key: keyof Scope, name: string, all: string[]) => { + const cur = scope[key].length ? [...scope[key]] : [...all]; + const i = cur.indexOf(name); + if (i >= 0) cur.splice(i, 1); + else cur.push(name); + commit(key, cur, all); + }; + + const section = (key: keyof Scope, list: [string, number][], strip: boolean) => + list.map(([name, count]) => ( + + )); + + return ( + + + {open && ( + + + + + {teams.length > 0 && ( + <> + + Teams + + + {teams.map(t => ( + + ))} + + + )} + + People + + {section('authors', authors, false)} + + Repos + + {section('repos', repos, true)} + + )} + + ); +} diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx new file mode 100644 index 00000000..539a0c36 --- /dev/null +++ b/frontend-v2/src/components/bits.tsx @@ -0,0 +1,140 @@ +import type { Status, Weight } from '../model/status'; +import { loginHue } from '../format'; + +export const STATUS_LABEL: Record = { + ready: 'Ready', + needs_recr: 'Re-stamp', + needs_qa: 'Needs QA', + needs_cr: 'Needs CR', + blocked: 'Blocked', + ci_red: 'CI red', + draft: 'Draft', +}; + +const STATUS_CLASS: Record = { + ready: 'badge-ready', + needs_recr: 'badge-recr', + needs_qa: 'badge-qa', + needs_cr: 'badge-cr', + blocked: 'badge-blocked', + ci_red: 'badge-red', + draft: 'badge-draft', +}; + +export const STATUS_DOT: Record = { + ready: 'var(--ok)', + needs_recr: 'var(--brand)', + needs_qa: 'var(--violet)', + needs_cr: 'var(--ink-3)', + blocked: 'var(--warn)', + ci_red: 'var(--bad)', + draft: 'var(--border)', +}; + +export function StatusBadge({ status }: { status: Status }) { + return {STATUS_LABEL[status]}; +} + +export function Avatar({ + login, + size = 22, + onClick, +}: { + login: string; + size?: number; + onClick?: (login: string) => void; +}) { + const style = { + background: `hsl(${loginHue(login)} 45% 45%)`, + width: size, + height: size, + fontSize: Math.round(size * 0.42), + }; + const cls = + 'inline-flex flex-none items-center justify-center rounded-full font-semibold uppercase text-white transition-[scale] duration-150 motion-reduce:transition-none'; + return onClick ? ( + + ) : ( + + {login.slice(0, 2)} + + ); +} + +export function WeightChip({ weight }: { weight: Weight }) { + return ( + + {weight} + + ); +} + +export function Pips({ + label, + have, + req, + stale, +}: { + label: string; + have: number; + req: number; + stale?: boolean; +}) { + const total = Math.max(req, have); + return ( + + {label} + {have === 0 && stale ? ( + + ) : ( + Array.from({ length: total }, (_, i) => ( + + )) + )} + + ); +} + +export function Heat({ days }: { days: number }) { + const n = Math.min(5, Math.ceil((days + 1) / 7)); + const tone = days >= 28 ? 'heat-hot' : days >= 14 ? 'heat-warm' : ''; + return ( + + {Array.from({ length: 5 }, (_, i) => ( + = n ? { opacity: 0.25 } : undefined} + /> + ))} + + ); +} + +export function EmptyState({ title, sub }: { title: string; sub: string }) { + return ( +
+ + + + + {title} + {sub} +
+ ); +} From d6e546b5aa16375d4d481e3c6625bf08d4badf2e Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 15:57:51 -0700 Subject: [PATCH 006/381] Add the three lenses: For you, People, Teams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board's job is picking your next review action in seconds, so the default lens (For you) ranks by leverage instead of listing columns: 1. Act now — re-stamps you owe, then other re-stamps, then merge-ready pulls. Minutes each; the cheapest wins lead. 2. Your PRs — whose move each one is. 3. Review queue — one ranked lane, lightest weight first, with the oldest starved pull pinned on top (the fairness interleave: big old PRs get a slot every session instead of losing to every 50-liner). 4. The rest of the board — QA/aging/blocked/red/drafts/bots as folded count lines. Nothing is hidden, nothing shouts. People answers "what's this person's state, and how can I help ship their work": pick a person, see what you can review of theirs, the rest of their board, and re-stamps they owe others. Teams is the same shape grouped by GitHub team (from the optional teams.json). The app shell carries the glanceable summary (open counts by status), the scope control, a text filter, and a changed-since-your-last-look banner that only exists when something moved. A v1 link in the header keeps the classic board one click away. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 190 +++++++++++++++++++++++++++++++ frontend-v2/src/main.tsx | 13 +++ frontend-v2/src/views/ForYou.tsx | 168 +++++++++++++++++++++++++++ frontend-v2/src/views/People.tsx | 144 +++++++++++++++++++++++ frontend-v2/src/views/Teams.tsx | 125 ++++++++++++++++++++ 5 files changed, 640 insertions(+) create mode 100644 frontend-v2/src/app.tsx create mode 100644 frontend-v2/src/main.tsx create mode 100644 frontend-v2/src/views/ForYou.tsx create mode 100644 frontend-v2/src/views/People.tsx create mode 100644 frontend-v2/src/views/Teams.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx new file mode 100644 index 00000000..a2aa3133 --- /dev/null +++ b/frontend-v2/src/app.tsx @@ -0,0 +1,190 @@ +import { useEffect, useMemo, useState } from 'react'; +import { STATUS_ORDER, type DerivedPull } from './model/status'; +import type { Team } from './types'; +import { usePulldasher } from './store'; +import { loadTeams, useScope } from './prefs'; +import { ScopeControl } from './components/Scope'; +import { STATUS_LABEL } from './components/bits'; +import type { RowOptions } from './components/Row'; +import { ForYou } from './views/ForYou'; +import { People } from './views/People'; +import { Teams } from './views/Teams'; + +type Lens = 'you' | 'people' | 'teams'; + +const BOT_LOGINS = new Set(['ifixit-systems']); +const isBot = (p: DerivedPull) => + p.data.user.login.endsWith('[bot]') || BOT_LOGINS.has(p.data.user.login); + +export function App() { + const { pulls, me, connection, lastSeen } = usePulldasher(); + const [scope] = useScope(); + const [lens, setLens] = useState('you'); + const [person, setPerson] = useState(null); + const [team, setTeam] = useState(null); + const [query, setQuery] = useState(''); + const [onlyChanged, setOnlyChanged] = useState(false); + const [teams, setTeams] = useState([]); + const [dark, setDark] = useState(() => matchMedia('(prefers-color-scheme: dark)').matches); + + useEffect(() => { + void loadTeams().then(setTeams); + }, []); + useEffect(() => { + document.documentElement.classList.toggle('dark', dark); + }, [dark]); + + const scoped = useMemo(() => { + let out = pulls; + if (scope.repos.length) out = out.filter(p => scope.repos.includes(p.data.repo)); + if (scope.authors.length) + out = out.filter(p => isBot(p) || scope.authors.includes(p.data.user.login)); + if (query) { + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + out = out.filter(p => + terms.every( + t => + p.data.title.toLowerCase().includes(t) || + p.data.repo.toLowerCase().includes(t) || + p.data.user.login.toLowerCase().includes(t) + ) + ); + } + if (onlyChanged) out = out.filter(p => Date.parse(p.data.updated_at) / 1000 > lastSeen); + return out; + }, [pulls, scope, query, onlyChanged, lastSeen]); + + const humans = scoped.filter(p => !isBot(p)); + const bots = scoped.filter(isBot); + const changedCount = pulls.filter( + p => !isBot(p) && Date.parse(p.data.updated_at) / 1000 > lastSeen + ).length; + + const statusCounts = new Map(); + for (const p of humans) statusCounts.set(p.status, (statusCounts.get(p.status) ?? 0) + 1); + const isScoped = scope.repos.length || scope.authors.length || query || onlyChanged; + + const rowOpts: RowOptions = { + me, + lastSeen, + onPerson: login => { + setPerson(login); + setLens('people'); + }, + }; + + const tab = (id: Lens, label: string) => ( + + ); + + return ( + <> +
+
+ + pulldasher + + + + + {isScoped ? `${scoped.length} of ${pulls.length}` : pulls.length} + {' '} + open ·{' '} + {STATUS_ORDER.filter(s => statusCounts.get(s)).map((s, i) => ( + + {i > 0 && ' · '} + {statusCounts.get(s)} {STATUS_LABEL[s].toLowerCase()} + + ))} + + + {me ? `viewing as ${me}` : '…'} + + v1 board + + +
+
+ + + setQuery(e.target.value)} + className="h-8 w-[170px] rounded-lg border border-line bg-surface px-2.5 text-[13px]" + /> + + {bots.length} bot PRs below +
+
+ + {changedCount > 0 && !query && ( +
+
+ + + {changedCount} PRs changed since your last + look + + +
+
+ )} + +
+ {lens === 'you' && } + {lens === 'people' && ( + !isBot(p))} + teams={teams} + person={person} + onPerson={setPerson} + opts={rowOpts} + /> + )} + {lens === 'teams' && ( + + )} +
+ + ); +} diff --git a/frontend-v2/src/main.tsx b/frontend-v2/src/main.tsx new file mode 100644 index 00000000..72ae69db --- /dev/null +++ b/frontend-v2/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './app'; +import './styles.css'; + +// eslint-disable-next-line no-console +console.log('%cpulldasher v2', 'color:#0071CE;font-weight:700', 'wrench in hand.'); + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/frontend-v2/src/views/ForYou.tsx b/frontend-v2/src/views/ForYou.tsx new file mode 100644 index 00000000..2c496d50 --- /dev/null +++ b/frontend-v2/src/views/ForYou.tsx @@ -0,0 +1,168 @@ +import { Fragment } from 'react'; +import { isIterating, weightRank, type DerivedPull } from '../model/status'; +import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; +import { DividerLine, Fold, Lane, RestGroup } from '../components/Lane'; +import { Row, type RowOptions } from '../components/Row'; + +/** lightest first; actively-iterating pulls sink (demoted, never hidden) */ +export function crSort(pulls: DerivedPull[]): DerivedPull[] { + return [...pulls].sort( + (a, b) => + Number(isIterating(a.data)) - Number(isIterating(b.data)) || + weightRank(a.weight) - weightRank(b.weight) || + (a.data.additions ?? 0) + + (a.data.deletions ?? 0) - + ((b.data.additions ?? 0) + (b.data.deletions ?? 0)) + ); +} + +export function ForYou({ + pulls, + bots, + opts, +}: { + pulls: DerivedPull[]; + bots: DerivedPull[]; + opts: RowOptions; +}) { + const me = opts.me; + const yours = pulls.filter(p => p.data.user.login === me); + const others = pulls.filter(p => p.data.user.login !== me); + + // 1. Act now: re-stamps (yours first), then merge nudges. Minutes each. + const actNow = [ + ...others.filter(p => p.status === 'needs_recr' && p.recrBy.includes(me)), + ...others.filter(p => p.status === 'needs_recr' && !p.recrBy.includes(me)), + ...others.filter(p => p.status === 'ready'), + ]; + + // 2. Review queue: oldest debt pinned on top, then lightest to heaviest. + const crPool = others.filter(p => p.status === 'needs_cr' && !p.crBy.includes(me)); + const aged = crPool.filter(p => p.starved).sort((a, b) => b.starveScore - a.starveScore); + const pick = aged[0]; + const queue = crSort(crPool.filter(p => p !== pick && !p.starved)); + const firstHeavy = queue.findIndex(p => weightRank(p.weight) >= 2); + + // 3. Everything else stays countable but folded. + const stamped = others.filter(p => p.status === 'needs_cr' && p.crBy.includes(me)); + const restAging = aged.slice(1); + const rowKey = (p: DerivedPull) => `${p.data.repo}#${p.data.number}`; + const foldRows = (list: DerivedPull[], o: Partial = {}) => + list + .slice(0, 30) + .map(p => ); + + const empty = !actNow.length && !yours.length && !crPool.length && !others.length; + + return ( + <> + + + {crPool.length > 0 && ( + + {pick && ( + + )} + {queue.slice(0, 9).map((p, i) => ( + + {i === firstHeavy && firstHeavy > 0 && ( + + )} + + + ))} + {crPool.length - Math.min(9, queue.length) - (pick ? 1 : 0) > 0 && ( + + )} + + )} + {empty ? ( + + ) : ( + + p.status === 'needs_qa').length} + label={STATUS_LABEL.needs_qa.toLowerCase()} + hint="authors drive QA, nudge only" + > + {foldRows( + others.filter(p => p.status === 'needs_qa'), + { pips: 'qa' } + )} + + + {foldRows(restAging, { pips: 'cr', aging: true, badge: false })} + + + {foldRows(stamped, { pips: 'cr', badge: false })} + + p.status === 'blocked').length} + label="blocked" + hint="find who holds the block" + > + {foldRows(others.filter(p => p.status === 'blocked'))} + + p.status === 'ci_red').length} + label="CI red" + hint="authors’ move" + > + {foldRows(others.filter(p => p.status === 'ci_red'))} + + p.status === 'draft').length} + label="drafts" + hint="not reviewable yet" + > + {foldRows(others.filter(p => p.status === 'draft'))} + + + {foldRows(bots)} + + + )} + + ); +} diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx new file mode 100644 index 00000000..95af56fa --- /dev/null +++ b/frontend-v2/src/views/People.tsx @@ -0,0 +1,144 @@ +import { STATUS_ORDER, type DerivedPull } from '../model/status'; +import type { Team } from '../types'; +import { Avatar } from '../components/bits'; +import { Fold, Lane, RestGroup } from '../components/Lane'; +import { Row, type RowOptions } from '../components/Row'; +import { crSort } from './ForYou'; + +export function People({ + pulls, + allPulls, + teams, + person, + onPerson, + opts, +}: { + /** scoped pool (what the lanes show) */ + pulls: DerivedPull[]; + /** unscoped pool (for the picker counts and owed re-stamps) */ + allPulls: DerivedPull[]; + teams: Team[]; + person: string | null; + onPerson: (login: string) => void; + opts: RowOptions; +}) { + const counts = new Map(); + for (const p of allPulls) + counts.set(p.data.user.login, (counts.get(p.data.user.login) ?? 0) + 1); + const owes = new Map(); + for (const p of allPulls) for (const u of p.recrBy) owes.set(u, [...(owes.get(u) ?? []), p]); + + const logins = [...new Set([...counts.keys(), ...owes.keys()])].sort( + (a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0) + ); + const selected = person && logins.includes(person) ? person : logins[0]; + if (!selected) return null; + + const theirs = pulls.filter(p => p.data.user.login === selected); + const owed = owes.get(selected) ?? []; + const reviewable = crSort( + theirs.filter( + p => ['needs_cr', 'needs_recr'].includes(p.status) && p.data.user.login !== opts.me + ) + ); + const rest = theirs + .filter(p => !reviewable.includes(p)) + .sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status)); + const team = teams.find(t => t.members.includes(selected))?.team; + const shipping = theirs.filter(p => ['ready', 'needs_qa'].includes(p.status)).length; + + return ( + <> +
+ {logins.slice(0, 24).map(login => ( + + ))} +
+ +
+ + + {selected} +
+ + {team ? `${team} · ` : ''} + {theirs.length} open PRs + +
+ + + + {reviewable.length} + + you can review + + + {shipping} + close to shipping + + + + {owed.length} + + re-stamps owed + + +
+ + + {(rest.length > 0 || owed.length > 0) && ( + + + {rest.map(p => ( + + ))} + + + {owed.map(p => ( + + ))} + + + )} + + ); +} diff --git a/frontend-v2/src/views/Teams.tsx b/frontend-v2/src/views/Teams.tsx new file mode 100644 index 00000000..ee95965c --- /dev/null +++ b/frontend-v2/src/views/Teams.tsx @@ -0,0 +1,125 @@ +import { STATUS_ORDER, type DerivedPull } from '../model/status'; +import type { Team } from '../types'; +import { Avatar, EmptyState, STATUS_LABEL } from '../components/bits'; +import { Fold, Lane, RestGroup } from '../components/Lane'; +import { Row, type RowOptions } from '../components/Row'; +import { crSort } from './ForYou'; + +export function Teams({ + pulls, + teams, + team, + onTeam, + opts, +}: { + pulls: DerivedPull[]; + teams: Team[]; + team: string | null; + onTeam: (team: string) => void; + opts: RowOptions; +}) { + if (!teams.length) { + return ( + + ); + } + + const memberOf = (login: string) => teams.find(t => t.members.includes(login))?.team; + const byTeam = new Map(); + for (const t of teams) byTeam.set(t.team, []); + byTeam.set('everyone else', []); + for (const p of pulls) byTeam.get(memberOf(p.data.user.login) ?? 'everyone else')!.push(p); + const nonEmpty = [...byTeam.entries()].filter(([, v]) => v.length); + const selected = team && byTeam.get(team)?.length ? team : nonEmpty[0]?.[0]; + if (!selected) return ; + + const teamPulls = byTeam.get(selected)!; + const members = teams.find(t => t.team === selected)?.members ?? [ + ...new Set(teamPulls.map(p => p.data.user.login)), + ]; + const statusCounts = new Map(); + for (const p of teamPulls) statusCounts.set(p.status, (statusCounts.get(p.status) ?? 0) + 1); + + const reviewable = crSort( + teamPulls.filter( + p => ['needs_cr', 'needs_recr'].includes(p.status) && p.data.user.login !== opts.me + ) + ); + const rest = teamPulls + .filter(p => !reviewable.includes(p)) + .sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status)); + + return ( + <> +
+ {nonEmpty.map(([name, v]) => ( + + ))} +
+ +
+ + {members.slice(0, 12).map(m => ( + + ))} + + + {selected} +
+ + {members.length} members · {teamPulls.length} open PRs + +
+ + {[...statusCounts.entries()].map(([status, count]) => ( + + {count} + {STATUS_LABEL[status as keyof typeof STATUS_LABEL].toLowerCase()} + + ))} + +
+ + + {rest.length > 0 && ( + + + {rest.slice(0, 20).map(p => ( + + ))} + + + )} + + ); +} From dca50ae80eda17d3191641c574a8d5ef25f4a36f Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 15:57:52 -0700 Subject: [PATCH 007/381] Serve frontend-v2 at /v2 beside the classic board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mount frontend-v2/dist at /v2 ahead of the v1 catch-all and gate its index behind the same GitHub-OAuth check as / and /token. Both frontends now run against the same server, socket protocol, and DB — switching is just a URL, so v2 can be adopted (and rolled back) per-person while v1 stays the default at /. Root package.json gets pass-through scripts (frontend2:dev, frontend2:dummy, build:v2); v2 deps stay isolated in frontend-v2's own package.json. teams.json is per-deploy config, so it's gitignored with a committed example. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + app.js | 1 + frontend-v2/public/teams.example.json | 4 ++++ lib/authentication.js | 2 ++ package.json | 5 ++++- 5 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 frontend-v2/public/teams.example.json diff --git a/.gitignore b/.gitignore index 03a593a2..a5dc6f6b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ pulldasher.pid dist db_data/ /.env.db +frontend-v2/public/teams.json diff --git a/app.js b/app.js index 0c79e298..369709a3 100644 --- a/app.js +++ b/app.js @@ -53,6 +53,7 @@ app.use(function (req, res, next) { * Routes */ authManager.setupRoutes(app); +app.use("/v2", express.static(__dirname + "/frontend-v2/dist")); app.use("/", express.static(__dirname + "/frontend/dist")); app.get("/token", mainController.getToken); app.post("/hooks/main", hooksController.main); diff --git a/frontend-v2/public/teams.example.json b/frontend-v2/public/teams.example.json new file mode 100644 index 00000000..5bbc6f7e --- /dev/null +++ b/frontend-v2/public/teams.example.json @@ -0,0 +1,4 @@ +[ + { "team": "carpenter", "members": ["alice", "bob"] }, + { "team": "nextjs", "members": ["carol"] } +] diff --git a/lib/authentication.js b/lib/authentication.js index 24812565..0a9c84f7 100644 --- a/lib/authentication.js +++ b/lib/authentication.js @@ -117,6 +117,8 @@ export default { res.redirect("/auth/github"); }; app.get("/", auth); + app.get("/v2", auth); + app.get("/v2/", auth); app.get("/token", auth); }, }; diff --git a/package.json b/package.json index 5c579671..a7bd6230 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,10 @@ "postinstall": "npm run build", "build": "webpack --config ./frontend/webpack.prod.config.js", "test": "CONFIG_PATH=../test/fixtures/config.js node --test test/*.test.js", - "precommit": "lint-staged" + "precommit": "lint-staged", + "frontend2:dev": "cd frontend-v2 && npm run dev", + "frontend2:dummy": "cd frontend-v2 && npm run dev:dummy", + "build:v2": "cd frontend-v2 && npm install && npm run build" }, "lint-staged": { "*.(js|jsx|ts|tsx)": [ From dcd9b2c11fcd4d522f4c1acf79e10c108bbe2142 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 16:24:40 -0700 Subject: [PATCH 008/381] Model the v1 signals v2 dropped: conflicts, dependents, CI-pending, labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scaffold's derivation missed four things v1 tracked, and one of them was outright wrong: a merge-conflicted PR could show "Ready". - mergeable === false and a non-master base now flag every row and gate ready: a signed-off-and-green pull that can't merge as-is lands in blocked, matching v1's deploy-blocked semantics. While review is still pending they stay flags, not gates — a conflict shouldn't hide a pull from reviewers. - ci_pending joins the status vocabulary, but only at the ready gate: pending CI never pulls a PR out of the review lanes (reviews don't need green), while a fully-signed-off pull isn't "ready" until CI agrees. Deliberate divergence from v1, which gated all lanes on green and hid freshly-pushed work for the length of a CI run. - The v1 label conventions are back: QAing (who's already testing), external_block, and Cryogenic Storage, plus blockedBy naming the dev/deploy-block holder. - The store keeps the server's 14-day closed-pull window instead of filtering it out, for recently-shipped views. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/bits.tsx | 3 ++ frontend-v2/src/model/status.test.ts | 64 ++++++++++++++++++++++++++++ frontend-v2/src/model/status.ts | 44 +++++++++++++++++-- frontend-v2/src/store.ts | 19 +++++++-- frontend-v2/src/styles.css | 19 +++++++++ 5 files changed, 142 insertions(+), 7 deletions(-) diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 539a0c36..5d60a095 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -3,6 +3,7 @@ import { loginHue } from '../format'; export const STATUS_LABEL: Record = { ready: 'Ready', + ci_pending: 'CI running', needs_recr: 'Re-stamp', needs_qa: 'Needs QA', needs_cr: 'Needs CR', @@ -13,6 +14,7 @@ export const STATUS_LABEL: Record = { const STATUS_CLASS: Record = { ready: 'badge-ready', + ci_pending: 'badge-blocked', needs_recr: 'badge-recr', needs_qa: 'badge-qa', needs_cr: 'badge-cr', @@ -23,6 +25,7 @@ const STATUS_CLASS: Record = { export const STATUS_DOT: Record = { ready: 'var(--ok)', + ci_pending: 'var(--warn)', needs_recr: 'var(--brand)', needs_qa: 'var(--violet)', needs_cr: 'var(--ink-3)', diff --git a/frontend-v2/src/model/status.test.ts b/frontend-v2/src/model/status.test.ts index 8850dfdb..dd3ab600 100644 --- a/frontend-v2/src/model/status.test.ts +++ b/frontend-v2/src/model/status.test.ts @@ -126,6 +126,70 @@ describe('status derivation precedence', () => { }); expect(derive(p, undefined, NOW).status).toBe('needs_cr'); }); + + it('pending CI never hides a pull from review, but gates ready', () => { + const needsCr = withStatus({ commit_statuses: [ci('pending')] }); + expect(derive(needsCr, undefined, NOW).status).toBe('needs_cr'); + + const signedOff = withStatus({ + allCR: [sig('CR', 'r', true)], + allQA: [sig('QA', 'q', true)], + commit_statuses: [ci('pending')], + }); + expect(derive(signedOff, undefined, NOW).status).toBe('ci_pending'); + }); + + it('conflicts and a dependent base flag everywhere and block ready', () => { + const conflicted = withStatus( + { allCR: [sig('CR', 'r', true)], allQA: [sig('QA', 'q', true)] }, + { mergeable: false } + ); + const d = derive(conflicted, undefined, NOW); + expect(d.conflict).toBe(true); + expect(d.status).toBe('blocked'); + + const dependent = withStatus( + { allCR: [sig('CR', 'r', true)], allQA: [sig('QA', 'q', true)] }, + { base: { ref: 'parent-feature' } } + ); + const dd = derive(dependent, undefined, NOW); + expect(dd.dependent).toBe(true); + expect(dd.status).toBe('blocked'); + + // still reviewable while CR is missing: conflict is a flag, not a gate + const needsCr = pull({ mergeable: false }); + expect(derive(needsCr, undefined, NOW).status).toBe('needs_cr'); + }); + + it('reads the v1 label conventions', () => { + const labeled = pull({ + labels: [ + { + title: 'QAing', + number: 1, + repo: 'iFixit/ifixit', + user: 'tester', + created_at: '2026-01-01T00:00:00Z', + }, + { + title: 'Cryogenic Storage', + number: 1, + repo: 'iFixit/ifixit', + user: 'x', + created_at: '2026-01-01T00:00:00Z', + }, + ], + }); + const d = derive(labeled, undefined, NOW); + expect(d.qaingBy).toBe('tester'); + expect(d.cryo).toBe(true); + expect(d.externalBlock).toBe(false); + }); + + it('names who holds a block', () => { + const p = withStatus({ dev_block: [sig('dev_block', 'holder', true)] }); + expect(derive(p, undefined, NOW).blockedBy).toBe('holder'); + }); }); function ci(state: 'success' | 'failure' | 'pending' | 'error', context = 'build', sha = 'abc') { diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index d4bd4a8b..f8c77f1b 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -4,7 +4,13 @@ import type { CommitStatus, PullData, RepoSpec, Signature } from '../types'; * One pull, one status. Mutually exclusive by precedence — the fix for v1's * six overlapping column predicates (a pull could sit in CR and QA at once). * Precedence mirrors the what-to-review skill's derivation: - * draft > blocked > ci_red > needs_recr > needs_cr > needs_qa > ready + * draft > blocked > ci_red > needs_recr > needs_cr > needs_qa > + * ci_pending > ready + * + * ci_pending exists only at the ready gate: pending CI never hides a pull + * from the review lanes (reviews don't need green), but a fully-signed-off + * pull isn't "ready" until CI agrees. Merge conflicts and a dependent base + * gate the same way: reviewable as usual, but never "ready". */ export type Status = | 'draft' @@ -13,6 +19,7 @@ export type Status = | 'needs_recr' | 'needs_cr' | 'needs_qa' + | 'ci_pending' | 'ready'; export const STATUS_ORDER: Status[] = [ @@ -20,11 +27,19 @@ export const STATUS_ORDER: Status[] = [ 'needs_recr', 'needs_qa', 'needs_cr', + 'ci_pending', 'blocked', 'ci_red', 'draft', ]; +/** v1's label conventions, still in active use on the boards. */ +export const LABELS = { + qaing: 'QAing', + externalBlock: 'external_block', + cryo: 'Cryogenic Storage', +} as const; + export type CiVerdict = 'success' | 'pending' | 'failing' | 'none'; export interface DerivedPull { @@ -45,6 +60,16 @@ export interface DerivedPull { starved: boolean; starveScore: number; weight: Weight; + /** mergeable === false: shows as a flag everywhere, gates "ready" */ + conflict: boolean; + /** base isn't main/master: lands with its parent, gates "ready" */ + dependent: boolean; + /** who holds the active dev/deploy block, if any */ + blockedBy: string | null; + /** login from the QAing label: someone is already testing this */ + qaingBy: string | null; + externalBlock: boolean; + cryo: boolean; } export type Weight = 'XS' | 'S' | 'M' | 'L' | 'XL'; @@ -121,17 +146,24 @@ export function derive( .filter(u => !crBy.includes(u) && u !== pull.user.login) ); - const blocked = st.dev_block.length > 0 || st.deploy_block.length > 0; + const blockSig = st.dev_block[0] ?? st.deploy_block[0]; const crDone = crHave >= st.cr_req; const qaDone = qaHave >= st.qa_req; + const conflict = pull.mergeable === false; + const dependent = !['main', 'master'].includes(pull.base.ref); + const label = (title: string) => pull.labels.find(l => l.title === title); + let status: Status; if (pull.draft) status = 'draft'; - else if (blocked) status = 'blocked'; + else if (blockSig) status = 'blocked'; else if (ci === 'failing') status = 'ci_red'; else if (!crDone && staleCr.length) status = 'needs_recr'; else if (!crDone) status = 'needs_cr'; else if (!qaDone) status = 'needs_qa'; + else if (ci === 'pending') status = 'ci_pending'; + // signed off and green, but unmergeable as-is: that's a block, not ready + else if (conflict || dependent) status = 'blocked'; else status = 'ready'; const created = Date.parse(pull.created_at) / 1000; @@ -153,6 +185,12 @@ export function derive( starved, starveScore: starved ? ageDays * Math.max(size, 1) : 0, weight: reviewWeight(pull), + conflict, + dependent, + blockedBy: blockSig?.data.user.login ?? null, + qaingBy: label(LABELS.qaing)?.user ?? null, + externalBlock: !!label(LABELS.externalBlock), + cryo: !!label(LABELS.cryo), }; } diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts index af463161..7d2dc15c 100644 --- a/frontend-v2/src/store.ts +++ b/frontend-v2/src/store.ts @@ -12,6 +12,8 @@ import type { PullData, RepoSpec } from './types'; export interface Snapshot { pulls: DerivedPull[]; repoSpecs: RepoSpec[]; + /** merged/closed in the last 14 days (the server's retention window) */ + closed: PullData[]; me: string; connection: ConnectionState; /** epoch secs of the last-seen marker captured at page load */ @@ -30,14 +32,23 @@ const listeners = new Set<() => void>(); const lastSeen = Number(localStorage.getItem(LAST_SEEN_KEY)) || Date.now() / 1000 - 6 * 3600; localStorage.setItem(LAST_SEEN_KEY, String(Date.now() / 1000)); -let snapshot: Snapshot = { pulls: [], repoSpecs, me, connection, lastSeen }; +let snapshot: Snapshot = { + pulls: [], + repoSpecs, + closed: [], + me, + connection, + lastSeen, +}; function publish() { const specByName = new Map(repoSpecs.map(s => [s.name, s])); + const all = [...raw.values()]; snapshot = { - pulls: [...raw.values()] - .filter(p => p.state === 'open') - .map(p => derive(p, specByName.get(p.repo))), + pulls: all.filter(p => p.state === 'open').map(p => derive(p, specByName.get(p.repo))), + closed: all + .filter(p => p.state === 'closed') + .sort((a, b) => Date.parse(b.closed_at ?? '0') - Date.parse(a.closed_at ?? '0')), repoSpecs, me, connection, diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index b1e8a1d9..1b146ea6 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -158,6 +158,25 @@ a:hover { .heat-hot { background: var(--heat2); } } +/* hover-reveal row actions (copy branch, refresh) */ +.row-actions { + visibility: hidden; +} +.pd-row:hover .row-actions, +.row-actions:focus-within { + visibility: visible; +} + +.flag-qaing { + border-radius: 8px; + padding: 2px 7px; + font-size: 11px; + font-weight: 500; + background: var(--badge-purple-bg); + color: var(--badge-purple-fg); + flex: none; +} + /* ── Motion: state feedback only, exponential ease-out, one settle entrance ── */ @keyframes settle { from { From 6350fb596137b1686f18a99d5d6e5f6ec17e1df2 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 16:24:41 -0700 Subject: [PATCH 009/381] Row upgrades: warning flags, hover actions, closed-pull rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows now carry the v1 card affordances the scaffold dropped: conflict / dependent / external / CI-running flags, the QAing badge naming who is already testing, and hover-revealed actions — copy the branch name and re-fetch the pull from GitHub over the existing socket refresh event. ClosedRow renders the 14-day merged/closed window with a Merged/Closed badge and how long ago. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/ClosedRow.tsx | 33 ++++++++++ frontend-v2/src/components/Row.tsx | 80 ++++++++++++++++++++++-- 2 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 frontend-v2/src/components/ClosedRow.tsx diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx new file mode 100644 index 00000000..d069f79a --- /dev/null +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -0,0 +1,33 @@ +import type { PullData } from '../types'; +import { ago, githubUrl, shortRepo } from '../format'; +import { Avatar } from './bits'; + +export function ClosedRow({ pull }: { pull: PullData }) { + const merged = !!pull.merged_at; + const closedAt = Date.parse(pull.closed_at ?? pull.updated_at) / 1000; + return ( +
+ + {merged ? 'Merged' : 'Closed'} + + + + + {shortRepo(pull.repo)}#{pull.number} + + + {pull.title} + + + {ago(closedAt)} ago +
+ ); +} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 585464db..c5c1fb1c 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -1,6 +1,8 @@ +import { useState } from 'react'; import type { DerivedPull } from '../model/status'; import { isIterating } from '../model/status'; import { ago, githubUrl, shortRepo } from '../format'; +import { refreshPull } from '../store'; import { Avatar, Heat, Pips, StatusBadge, WeightChip } from './bits'; export interface RowOptions { @@ -25,14 +27,78 @@ function cue(p: DerivedPull, me: string): string | null { } if (p.status === 'ready') return `CR✓ QA✓ green: ${d.user.login === me ? 'your' : `${d.user.login}’s`} merge button`; - if (p.status === 'needs_qa') return 'CR✓, author drives QA'; - if (p.status === 'blocked') { - const block = d.status.dev_block[0] ?? d.status.deploy_block[0]; - return block ? `held by ${block.data.user.login}` : null; - } + if (p.status === 'needs_qa') + return p.qaingBy ? `${p.qaingBy} is QAing` : 'CR✓, author drives QA'; + if (p.status === 'ci_pending') return 'signed off, waiting on CI'; + if (p.status === 'blocked' && p.blockedBy) return `held by ${p.blockedBy}`; return null; } +/** Small warning flags that ride along regardless of lane. */ +function WarnFlags({ pull }: { pull: DerivedPull }) { + return ( + <> + {pull.conflict && ( + + conflicts + + )} + {pull.dependent && ( + + dependent + + )} + {pull.ci === 'pending' && pull.status !== 'ci_pending' && ( + + CI… + + )} + {pull.externalBlock && ( + + external + + )} + {pull.qaingBy && pull.status === 'needs_qa' && ( + + ◉ {pull.qaingBy} + + )} + + ); +} + +/** Hover actions: re-fetch from GitHub, copy the branch name. */ +function RowActions({ pull }: { pull: DerivedPull }) { + const [copied, setCopied] = useState(false); + return ( + + + + + ); +} + export function Row({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const d = pull.data; const iterating = isIterating(d); @@ -44,7 +110,7 @@ export function Row({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { return (
{opts.badge === false ? null : } @@ -61,6 +127,7 @@ export function Row({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { {d.title} + {iterating && ( )} {opts.aging && pull.starved && waiting {pull.ageDays}d} + {line && ( {line} From f6cbb70fce4013c694d7736149d21200ab1b2570 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 16:25:00 -0700 Subject: [PATCH 010/381] Split For-you into Review and My work; promote aging and QA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Your PRs" sitting inside the review flow mixed the reader's two jobs — reviewer and author — and both v1 and v2 suffered for it. The split makes the job the top-level structure: - Review holds only other people's work: act now, the queue, and two lanes promoted out of the folds because they're decision lanes, not reference — Aging (top 3 by starve score, always visible) and Needs QA (v1's QA column earned its keep; QAing-labelled rows sort last since someone's already on them). Blocked/CI/drafts/bots stay folded, joined by a recently-shipped fold. - My work holds only yours, split by whose move it is: "Your move" leads each row with an explicit verb (Merge it, Fix CI, Resolve conflicts, QA it), and "Waiting on others" names who to nudge and how long it's been — the answer neither board ever said out loud. crSort moves to model/sort.ts now that three views share it. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/model/sort.ts | 13 +++ frontend-v2/src/views/ForYou.tsx | 168 ----------------------------- frontend-v2/src/views/MyWork.tsx | 118 +++++++++++++++++++++ frontend-v2/src/views/People.tsx | 2 +- frontend-v2/src/views/Review.tsx | 177 +++++++++++++++++++++++++++++++ frontend-v2/src/views/Teams.tsx | 2 +- 6 files changed, 310 insertions(+), 170 deletions(-) create mode 100644 frontend-v2/src/model/sort.ts delete mode 100644 frontend-v2/src/views/ForYou.tsx create mode 100644 frontend-v2/src/views/MyWork.tsx create mode 100644 frontend-v2/src/views/Review.tsx diff --git a/frontend-v2/src/model/sort.ts b/frontend-v2/src/model/sort.ts new file mode 100644 index 00000000..28ea9886 --- /dev/null +++ b/frontend-v2/src/model/sort.ts @@ -0,0 +1,13 @@ +import { isIterating, weightRank, type DerivedPull } from './status'; + +/** lightest first; actively-iterating pulls sink (demoted, never hidden) */ +export function crSort(pulls: DerivedPull[]): DerivedPull[] { + return [...pulls].sort( + (a, b) => + Number(isIterating(a.data)) - Number(isIterating(b.data)) || + weightRank(a.weight) - weightRank(b.weight) || + (a.data.additions ?? 0) + + (a.data.deletions ?? 0) - + ((b.data.additions ?? 0) + (b.data.deletions ?? 0)) + ); +} diff --git a/frontend-v2/src/views/ForYou.tsx b/frontend-v2/src/views/ForYou.tsx deleted file mode 100644 index 2c496d50..00000000 --- a/frontend-v2/src/views/ForYou.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { Fragment } from 'react'; -import { isIterating, weightRank, type DerivedPull } from '../model/status'; -import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; -import { DividerLine, Fold, Lane, RestGroup } from '../components/Lane'; -import { Row, type RowOptions } from '../components/Row'; - -/** lightest first; actively-iterating pulls sink (demoted, never hidden) */ -export function crSort(pulls: DerivedPull[]): DerivedPull[] { - return [...pulls].sort( - (a, b) => - Number(isIterating(a.data)) - Number(isIterating(b.data)) || - weightRank(a.weight) - weightRank(b.weight) || - (a.data.additions ?? 0) + - (a.data.deletions ?? 0) - - ((b.data.additions ?? 0) + (b.data.deletions ?? 0)) - ); -} - -export function ForYou({ - pulls, - bots, - opts, -}: { - pulls: DerivedPull[]; - bots: DerivedPull[]; - opts: RowOptions; -}) { - const me = opts.me; - const yours = pulls.filter(p => p.data.user.login === me); - const others = pulls.filter(p => p.data.user.login !== me); - - // 1. Act now: re-stamps (yours first), then merge nudges. Minutes each. - const actNow = [ - ...others.filter(p => p.status === 'needs_recr' && p.recrBy.includes(me)), - ...others.filter(p => p.status === 'needs_recr' && !p.recrBy.includes(me)), - ...others.filter(p => p.status === 'ready'), - ]; - - // 2. Review queue: oldest debt pinned on top, then lightest to heaviest. - const crPool = others.filter(p => p.status === 'needs_cr' && !p.crBy.includes(me)); - const aged = crPool.filter(p => p.starved).sort((a, b) => b.starveScore - a.starveScore); - const pick = aged[0]; - const queue = crSort(crPool.filter(p => p !== pick && !p.starved)); - const firstHeavy = queue.findIndex(p => weightRank(p.weight) >= 2); - - // 3. Everything else stays countable but folded. - const stamped = others.filter(p => p.status === 'needs_cr' && p.crBy.includes(me)); - const restAging = aged.slice(1); - const rowKey = (p: DerivedPull) => `${p.data.repo}#${p.data.number}`; - const foldRows = (list: DerivedPull[], o: Partial = {}) => - list - .slice(0, 30) - .map(p => ); - - const empty = !actNow.length && !yours.length && !crPool.length && !others.length; - - return ( - <> - - - {crPool.length > 0 && ( - - {pick && ( - - )} - {queue.slice(0, 9).map((p, i) => ( - - {i === firstHeavy && firstHeavy > 0 && ( - - )} - - - ))} - {crPool.length - Math.min(9, queue.length) - (pick ? 1 : 0) > 0 && ( - - )} - - )} - {empty ? ( - - ) : ( - - p.status === 'needs_qa').length} - label={STATUS_LABEL.needs_qa.toLowerCase()} - hint="authors drive QA, nudge only" - > - {foldRows( - others.filter(p => p.status === 'needs_qa'), - { pips: 'qa' } - )} - - - {foldRows(restAging, { pips: 'cr', aging: true, badge: false })} - - - {foldRows(stamped, { pips: 'cr', badge: false })} - - p.status === 'blocked').length} - label="blocked" - hint="find who holds the block" - > - {foldRows(others.filter(p => p.status === 'blocked'))} - - p.status === 'ci_red').length} - label="CI red" - hint="authors’ move" - > - {foldRows(others.filter(p => p.status === 'ci_red'))} - - p.status === 'draft').length} - label="drafts" - hint="not reviewable yet" - > - {foldRows(others.filter(p => p.status === 'draft'))} - - - {foldRows(bots)} - - - )} - - ); -} diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx new file mode 100644 index 00000000..dbc43739 --- /dev/null +++ b/frontend-v2/src/views/MyWork.tsx @@ -0,0 +1,118 @@ +import type { DerivedPull } from '../model/status'; +import type { PullData } from '../types'; +import { ago } from '../format'; +import { EmptyState } from '../components/bits'; +import { Fold, Lane, RestGroup } from '../components/Lane'; +import { Row, type RowOptions } from '../components/Row'; +import { ClosedRow } from '../components/ClosedRow'; + +/** + * The author's tab: only PRs you own, split by whose move it is. The old + * "Your PRs" lane mixed both answers; here "do this next" and "nudge this + * person" never share a section. + */ + +/** Your-move verb per state; null = waiting on someone else. */ +function yourMove(p: DerivedPull): string | null { + if (p.status === 'ready') return 'Merge it'; + if (p.status === 'ci_red') return 'Fix CI'; + if (p.conflict) return 'Resolve conflicts'; + if (p.status === 'needs_qa' && !p.qaingBy) return 'QA it'; + if (p.status === 'draft') return 'Finish the draft'; + return null; +} + +function waitingOn(p: DerivedPull): string { + if (p.status === 'blocked' && p.blockedBy) return `held by ${p.blockedBy} — ask them to lift`; + if (p.status === 'needs_recr' && p.recrBy.length) { + const wait = p.headPushedAt ? ` (fix up ${ago(p.headPushedAt)})` : ''; + return `waiting on ${p.recrBy.join(', ')}’s re-stamp${wait}`; + } + if (p.status === 'needs_cr') return `waiting on first CR, ${p.ageDays}d old`; + if (p.status === 'needs_qa' && p.qaingBy) return `${p.qaingBy} is QAing now`; + if (p.status === 'ci_pending') return 'waiting on CI'; + return 'waiting'; +} + +export function MyWork({ + pulls, + closed, + opts, +}: { + pulls: DerivedPull[]; + closed: PullData[]; + opts: RowOptions; +}) { + const me = opts.me; + const mine = pulls.filter(p => p.data.user.login === me); + const move = mine.filter(p => yourMove(p) !== null); + const waiting = mine.filter(p => yourMove(p) === null); + const shipped = closed.filter(p => p.user.login === me); + + if (!mine.length && !shipped.length) { + return ( + + ); + } + + return ( + <> + + {move.map(p => ( +
+ + {yourMove(p)} + + + + +
+ ))} + {!move.length && ( +
+ Nothing needs you right now — it’s all in other people’s hands below. +
+ )} +
+ + {waiting.map(p => ( +
+ + + + + {waitingOn(p)} + +
+ ))} + {!waiting.length && ( +
+ Nothing is waiting on anyone else. +
+ )} +
+ {shipped.length > 0 && ( + + + {shipped.slice(0, 30).map(p => ( + + ))} + + + )} + + ); +} diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx index 95af56fa..a9e512cc 100644 --- a/frontend-v2/src/views/People.tsx +++ b/frontend-v2/src/views/People.tsx @@ -3,7 +3,7 @@ import type { Team } from '../types'; import { Avatar } from '../components/bits'; import { Fold, Lane, RestGroup } from '../components/Lane'; import { Row, type RowOptions } from '../components/Row'; -import { crSort } from './ForYou'; +import { crSort } from '../model/sort'; export function People({ pulls, diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx new file mode 100644 index 00000000..230fc147 --- /dev/null +++ b/frontend-v2/src/views/Review.tsx @@ -0,0 +1,177 @@ +import { Fragment } from 'react'; +import { weightRank, type DerivedPull } from '../model/status'; +import { crSort } from '../model/sort'; +import type { PullData } from '../types'; +import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; +import { DividerLine, Fold, Lane, RestGroup } from '../components/Lane'; +import { Row, type RowOptions } from '../components/Row'; +import { ClosedRow } from '../components/ClosedRow'; + +/** + * The reviewer's tab: other people's work only. Your own PRs live in + * My work — the two jobs never share a lane. + */ +export function Review({ + pulls, + bots, + closed, + opts, +}: { + pulls: DerivedPull[]; + bots: DerivedPull[]; + closed: PullData[]; + opts: RowOptions; +}) { + const me = opts.me; + const others = pulls.filter(p => p.data.user.login !== me); + + // 1. Act now: re-stamps (yours first), then merge nudges. Minutes each. + const actNow = [ + ...others.filter(p => p.status === 'needs_recr' && p.recrBy.includes(me)), + ...others.filter(p => p.status === 'needs_recr' && !p.recrBy.includes(me)), + ...others.filter(p => p.status === 'ready'), + ]; + + // 2. Review queue: lightest to heaviest. + const crPool = others.filter(p => p.status === 'needs_cr' && !p.crBy.includes(me)); + const aged = crPool.filter(p => p.starved).sort((a, b) => b.starveScore - a.starveScore); + const queue = crSort(crPool.filter(p => !p.starved)); + const firstHeavy = queue.findIndex(p => weightRank(p.weight) >= 2); + + // 3. Aging: its own visible lane — the fairness debt, not a footnote. + const agingShown = aged.slice(0, 3); + + // 4. Needs QA: a real lane again (v1's QA column earned it); QAing-label + // rows sort last since someone is already on them. + const needsQa = [...others.filter(p => p.status === 'needs_qa')].sort( + (a, b) => Number(!!a.qaingBy) - Number(!!b.qaingBy) + ); + + const stamped = others.filter(p => p.status === 'needs_cr' && p.crBy.includes(me)); + const rowKey = (p: DerivedPull) => `${p.data.repo}#${p.data.number}`; + const foldRows = (list: DerivedPull[], o: Partial = {}) => + list + .slice(0, 30) + .map(p => ); + + const empty = !actNow.length && !crPool.length && !needsQa.length && !others.length; + if (empty) { + return ( + + ); + } + + return ( + <> + + {crPool.length > 0 && ( + + {queue.slice(0, 9).map((p, i) => ( + + {i === firstHeavy && firstHeavy > 0 && ( + + )} + + + ))} + {queue.length > 9 && ( + + )} + + )} + {aged.length > 0 && ( + + {agingShown.map(p => ( + + ))} + {aged.length > 3 && } + + )} + + + + {foldRows(stamped, { pips: 'cr', badge: false })} + + p.status === 'blocked').length} + label="blocked" + hint="find who holds the block" + > + {foldRows(others.filter(p => p.status === 'blocked'))} + + p.status === 'ci_pending').length} + label={STATUS_LABEL.ci_pending.toLowerCase()} + hint="signed off, waiting on green" + > + {foldRows(others.filter(p => p.status === 'ci_pending'))} + + p.status === 'ci_red').length} + label="CI red" + hint="authors’ move" + > + {foldRows(others.filter(p => p.status === 'ci_red'))} + + p.status === 'draft').length} + label="drafts" + hint="not reviewable yet" + > + {foldRows(others.filter(p => p.status === 'draft'))} + + + {foldRows(bots)} + + + {closed.slice(0, 30).map(p => ( + + ))} + + + + ); +} diff --git a/frontend-v2/src/views/Teams.tsx b/frontend-v2/src/views/Teams.tsx index ee95965c..42d48c1f 100644 --- a/frontend-v2/src/views/Teams.tsx +++ b/frontend-v2/src/views/Teams.tsx @@ -3,7 +3,7 @@ import type { Team } from '../types'; import { Avatar, EmptyState, STATUS_LABEL } from '../components/bits'; import { Fold, Lane, RestGroup } from '../components/Lane'; import { Row, type RowOptions } from '../components/Row'; -import { crSort } from './ForYou'; +import { crSort } from '../model/sort'; export function Teams({ pulls, From 7fb49b02aef8f5f7a03c0e752de7f9b94e568f35 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 16:25:01 -0700 Subject: [PATCH 011/381] Add the Board lens and the five-tab shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Board restores v1's spatial model — every state as a side-by-side column — for the muscle memory the lens views don't serve. One deliberate difference: a pull appears in exactly one column (its highest-precedence status), so column counts are real and sum to the total; v1's overlapping predicates showed the same pull in CR and QA at once. Columns collapse per-column like v1's board. The shell grows to five tabs (Review · My work · People · Teams · Board), restores two v1 behaviors — Cryogenic-Storage pulls and hideByDefault repos stay off the board behind a "❄ N hidden" toggle, and `/` focuses the filter box — and shows your open-PR count on the My work tab. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 88 ++++++++++++++++++++++++++------ frontend-v2/src/views/Board.tsx | 90 +++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 16 deletions(-) create mode 100644 frontend-v2/src/views/Board.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index a2aa3133..bc39eb80 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { STATUS_ORDER, type DerivedPull } from './model/status'; import type { Team } from './types'; import { usePulldasher } from './store'; @@ -6,26 +6,30 @@ import { loadTeams, useScope } from './prefs'; import { ScopeControl } from './components/Scope'; import { STATUS_LABEL } from './components/bits'; import type { RowOptions } from './components/Row'; -import { ForYou } from './views/ForYou'; +import { Review } from './views/Review'; +import { MyWork } from './views/MyWork'; import { People } from './views/People'; import { Teams } from './views/Teams'; +import { Board } from './views/Board'; -type Lens = 'you' | 'people' | 'teams'; +type Lens = 'review' | 'mine' | 'people' | 'teams' | 'board'; const BOT_LOGINS = new Set(['ifixit-systems']); const isBot = (p: DerivedPull) => p.data.user.login.endsWith('[bot]') || BOT_LOGINS.has(p.data.user.login); export function App() { - const { pulls, me, connection, lastSeen } = usePulldasher(); + const { pulls, closed, repoSpecs, me, connection, lastSeen } = usePulldasher(); const [scope] = useScope(); - const [lens, setLens] = useState('you'); + const [lens, setLens] = useState('review'); const [person, setPerson] = useState(null); const [team, setTeam] = useState(null); const [query, setQuery] = useState(''); const [onlyChanged, setOnlyChanged] = useState(false); + const [showHidden, setShowHidden] = useState(false); const [teams, setTeams] = useState([]); const [dark, setDark] = useState(() => matchMedia('(prefers-color-scheme: dark)').matches); + const searchRef = useRef(null); useEffect(() => { void loadTeams().then(setTeams); @@ -33,9 +37,33 @@ export function App() { useEffect(() => { document.documentElement.classList.toggle('dark', dark); }, [dark]); + // v1's `/` hotkey: jump to the filter box from anywhere + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== '/' || e.metaKey || e.ctrlKey) return; + const t = e.target as HTMLElement; + if (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.isContentEditable) return; + e.preventDefault(); + searchRef.current?.focus(); + searchRef.current?.select(); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, []); + + const hiddenRepos = useMemo( + () => new Set(repoSpecs.filter(s => s.hideByDefault).map(s => s.name)), + [repoSpecs] + ); const scoped = useMemo(() => { let out = pulls; + // v1 conventions: Cryogenic-Storage pulls and hideByDefault repos stay + // off the board unless asked for (or the scope names the repo). + if (!showHidden) + out = out.filter( + p => !p.cryo && (!hiddenRepos.has(p.data.repo) || scope.repos.includes(p.data.repo)) + ); if (scope.repos.length) out = out.filter(p => scope.repos.includes(p.data.repo)); if (scope.authors.length) out = out.filter(p => isBot(p) || scope.authors.includes(p.data.user.login)); @@ -52,13 +80,16 @@ export function App() { } if (onlyChanged) out = out.filter(p => Date.parse(p.data.updated_at) / 1000 > lastSeen); return out; - }, [pulls, scope, query, onlyChanged, lastSeen]); + }, [pulls, scope, query, onlyChanged, lastSeen, showHidden, hiddenRepos]); const humans = scoped.filter(p => !isBot(p)); const bots = scoped.filter(isBot); const changedCount = pulls.filter( p => !isBot(p) && Date.parse(p.data.updated_at) / 1000 > lastSeen ).length; + const hiddenCount = pulls.filter( + p => p.cryo || (hiddenRepos.has(p.data.repo) && !scope.repos.includes(p.data.repo)) + ).length; const statusCounts = new Map(); for (const p of humans) statusCounts.set(p.status, (statusCounts.get(p.status) ?? 0) + 1); @@ -73,7 +104,8 @@ export function App() { }, }; - const tab = (id: Lens, label: string) => ( + const mineCount = humans.filter(p => p.data.user.login === me).length; + const tab = (id: Lens, label: string, count?: number) => ( ); return ( <>
-
+
- pulldasher + pulldasher
-
+
setQuery(e.target.value)} className="h-8 w-[170px] rounded-lg border border-line bg-surface px-2.5 text-[13px]" /> + {hiddenCount > 0 && ( + + )} - {bots.length} bot PRs below + {bots.length} bot PRs
{changedCount > 0 && !query && ( -
+
@@ -169,8 +221,11 @@ export function App() {
)} -
- {lens === 'you' && } +
+ {lens === 'review' && ( + + )} + {lens === 'mine' && } {lens === 'people' && ( )} + {lens === 'board' && }
); diff --git a/frontend-v2/src/views/Board.tsx b/frontend-v2/src/views/Board.tsx new file mode 100644 index 00000000..18cd305c --- /dev/null +++ b/frontend-v2/src/views/Board.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import type { DerivedPull, Status } from '../model/status'; +import { crSort } from '../model/sort'; +import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; +import { Row, type RowOptions } from '../components/Row'; + +/** + * The classic v1 spatial model: every state as a column, scanned side by + * side. One difference on purpose: a pull appears in exactly one column + * (its highest-precedence status), so the counts are real and sum to the + * total — v1's overlapping predicates showed the same pull in CR and QA. + */ + +const COLUMNS: { status: Status; hint: string }[] = [ + { status: 'ready', hint: 'merge these' }, + { status: 'needs_recr', hint: 'reviewed, fix pushed' }, + { status: 'needs_cr', hint: 'the review pool' }, + { status: 'needs_qa', hint: 'CR done' }, + { status: 'ci_pending', hint: 'signed off, CI running' }, + { status: 'ci_red', hint: 'authors fix first' }, + { status: 'blocked', hint: 'dev/deploy blocks, conflicts' }, + { status: 'draft', hint: 'not ready for eyes' }, +]; + +function Column({ + status, + hint, + pulls, + opts, +}: { + status: Status; + hint: string; + pulls: DerivedPull[]; + opts: RowOptions; +}) { + const [open, setOpen] = useState(true); + if (!pulls.length) return null; + const ordered = ['needs_cr', 'needs_recr'].includes(status) ? crSort(pulls) : pulls; + return ( +
+ + {open && ( +
+ {ordered.map(p => ( + + ))} +
+ )} +
+ ); +} + +export function Board({ pulls, opts }: { pulls: DerivedPull[]; opts: RowOptions }) { + if (!pulls.length) { + return ; + } + const byStatus = new Map(); + for (const p of pulls) byStatus.set(p.status, [...(byStatus.get(p.status) ?? []), p]); + return ( +
+ {COLUMNS.map(({ status, hint }) => ( + + ))} +
+ ); +} From 1bc1dc3c3dd0b601212672d107186189d83d0b67 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 17:23:54 -0700 Subject: [PATCH 012/381] Harden the data layer against the failure states review found An adversarial review pass (six independent reviewers) traced every connection/data edge case to code. This commit fixes the layer that lies to the user: - Loading is now a real state: an initialized flag flips on the first initialize payload, so 'Workbench clear' can no longer render while the socket is still connecting (or forever after an auth failure). - /token failures were swallowed entirely: no r.ok check, no .catch on whoami, so an expired session left me='' with the user's own PRs leaking into the Review tab and My work celebrating an empty board. Failures now surface as a sign-in banner, and connect_error/token rejection drive the previously-unreachable 'error' connection state. - Disconnects were a 7px dot: stale data looked live. There is now a banner with the age of the last payload. - Reconnect merged the fresh initialize snapshot into the stale map, so pulls dropped while disconnected lived on as ghosts. The map is cleared and replaced. - localStorage was touched at module scope with no try/catch: blocked storage white-screened the app before React mounted. All storage goes through a safe helper now. - The last-seen marker was overwritten at page load, so one accidental refresh (or a second tab) erased 'changed since yesterday'. It now stamps on pagehide/visibility-hidden: it advances when you leave. - Blocked semantics: only *active* dev/deploy blocks count (a lifted block deactivates its signature, same as a stale CR stamp), and all holders are named instead of the first one - the review found the ask-one-person-then-discover-the-next whack-a-mole. - mergeable: null (GitHub still computing) was displayed as mergeable; ready rows now carry a 'mergeable?' flag instead of asserting. - teams.json entries are validated so one malformed entry can't crash every team surface. - derive() results are cached per PullData reference and a 60s tick expires time-based derivations on a quiet board. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 62 ++++++++++++++++++++++--- frontend-v2/src/backend/socket.ts | 19 +++++++- frontend-v2/src/components/Row.tsx | 7 ++- frontend-v2/src/model/status.test.ts | 27 +++++++++-- frontend-v2/src/model/status.ts | 20 ++++++-- frontend-v2/src/prefs.ts | 17 +++++-- frontend-v2/src/storage.ts | 20 ++++++++ frontend-v2/src/store.ts | 69 ++++++++++++++++++++++++---- frontend-v2/src/views/MyWork.tsx | 3 +- 9 files changed, 212 insertions(+), 32 deletions(-) create mode 100644 frontend-v2/src/storage.ts diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index bc39eb80..2e129e79 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; +import { ago } from './format'; import { STATUS_ORDER, type DerivedPull } from './model/status'; import type { Team } from './types'; import { usePulldasher } from './store'; @@ -19,7 +20,17 @@ const isBot = (p: DerivedPull) => p.data.user.login.endsWith('[bot]') || BOT_LOGINS.has(p.data.user.login); export function App() { - const { pulls, closed, repoSpecs, me, connection, lastSeen } = usePulldasher(); + const { + pulls, + closed, + repoSpecs, + me, + connection, + initialized, + authFailed, + lastPayloadAt, + lastSeen, + } = usePulldasher(); const [scope] = useScope(); const [lens, setLens] = useState('review'); const [person, setPerson] = useState(null); @@ -202,6 +213,37 @@ export function App() {
+ {authFailed && ( +
+
+ Sign-in failed. + + Your session may have expired.{' '} + + Reload to sign in again + + . + +
+
+ )} + {initialized && (connection === 'disconnected' || connection === 'error') && ( +
+
+ Live updates lost. + + Showing data as of {lastPayloadAt ? `${ago(lastPayloadAt)} ago` : 'page load'}, + retrying in the background. + +
+
+ )} {changedCount > 0 && !query && (
@@ -222,11 +264,19 @@ export function App() { )}
- {lens === 'review' && ( + {!initialized && !authFailed && ( +
+ + Loading the board… +
+ )} + {initialized && lens === 'review' && ( )} - {lens === 'mine' && } - {lens === 'people' && ( + {initialized && lens === 'mine' && ( + + )} + {initialized && lens === 'people' && ( !isBot(p))} @@ -236,10 +286,10 @@ export function App() { opts={rowOpts} /> )} - {lens === 'teams' && ( + {initialized && lens === 'teams' && ( )} - {lens === 'board' && } + {initialized && lens === 'board' && }
); diff --git a/frontend-v2/src/backend/socket.ts b/frontend-v2/src/backend/socket.ts index eec72b75..532c743a 100644 --- a/frontend-v2/src/backend/socket.ts +++ b/frontend-v2/src/backend/socket.ts @@ -18,10 +18,19 @@ function liveBackend(): Backend { let token: Promise | null = null; const getToken = () => { - token = token ?? fetch('/token').then(r => r.json()); + token = + token ?? + fetch('/token').then(async r => { + if (!r.ok) throw new Error(`token fetch failed: ${r.status}`); + const t = (await r.json()) as TokenResponse; + if (!t.socketToken) throw new Error('token response missing socketToken'); + return t; + }); return token; }; + let onState: ((state: ConnectionState) => void) | null = null; + const getSocket = () => { if (socket) return socket; socket = io(); @@ -29,8 +38,12 @@ function liveBackend(): Backend { // Socket tokens are single-use and expire quickly: re-fetch on every // (re)connect rather than reusing the first one. token = null; - void getToken().then(t => socket!.emit('authenticate', t.socketToken)); + getToken().then( + t => socket!.emit('authenticate', t.socketToken), + () => onState?.('error') + ); }); + socket.on('connect_error', () => onState?.('error')); return socket; }; @@ -42,6 +55,7 @@ function liveBackend(): Backend { s.on('pullChange', (pull: PullData) => handler(pull)); }, onConnection(handler) { + onState = handler; const s = getSocket(); const connected = () => handler('connected'); const disconnected = () => handler('disconnected'); @@ -50,6 +64,7 @@ function liveBackend(): Backend { s.on('disconnect', disconnected); s.io.on('reconnect_attempt', connecting); return () => { + onState = null; s.off('connect', connected); s.off('disconnect', disconnected); s.io.off('reconnect_attempt', connecting); diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index c5c1fb1c..90e3f683 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -30,7 +30,7 @@ function cue(p: DerivedPull, me: string): string | null { if (p.status === 'needs_qa') return p.qaingBy ? `${p.qaingBy} is QAing` : 'CR✓, author drives QA'; if (p.status === 'ci_pending') return 'signed off, waiting on CI'; - if (p.status === 'blocked' && p.blockedBy) return `held by ${p.blockedBy}`; + if (p.status === 'blocked' && p.blockedBy.length) return `blocked by ${p.blockedBy.join(', ')}`; return null; } @@ -51,6 +51,11 @@ function WarnFlags({ pull }: { pull: DerivedPull }) { dependent )} + {pull.mergeUnknown && pull.status === 'ready' && ( + + mergeable? + + )} {pull.ci === 'pending' && pull.status !== 'ci_pending' && ( CI… diff --git a/frontend-v2/src/model/status.test.ts b/frontend-v2/src/model/status.test.ts index dd3ab600..d80baec3 100644 --- a/frontend-v2/src/model/status.test.ts +++ b/frontend-v2/src/model/status.test.ts @@ -186,9 +186,30 @@ describe('status derivation precedence', () => { expect(d.externalBlock).toBe(false); }); - it('names who holds a block', () => { - const p = withStatus({ dev_block: [sig('dev_block', 'holder', true)] }); - expect(derive(p, undefined, NOW).blockedBy).toBe('holder'); + it('names everyone holding a block', () => { + const p = withStatus({ + dev_block: [sig('dev_block', 'holder', true)], + deploy_block: [sig('deploy_block', 'ops', true)], + }); + expect(derive(p, undefined, NOW).blockedBy).toEqual(['holder', 'ops']); + }); + + it('a lifted (inactive) block no longer blocks', () => { + const p = withStatus({ dev_block: [sig('dev_block', 'holder', false)] }); + const d = derive(p, undefined, NOW); + expect(d.status).toBe('needs_cr'); + expect(d.blockedBy).toEqual([]); + }); + + it('unknown mergeability is flagged, not asserted', () => { + const p = withStatus( + { allCR: [sig('CR', 'r', true)], allQA: [sig('QA', 'q', true)] }, + { mergeable: null } + ); + const d = derive(p, undefined, NOW); + expect(d.mergeUnknown).toBe(true); + expect(d.conflict).toBe(false); + expect(d.status).toBe('ready'); }); }); diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index f8c77f1b..3d55cec2 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -62,10 +62,14 @@ export interface DerivedPull { weight: Weight; /** mergeable === false: shows as a flag everywhere, gates "ready" */ conflict: boolean; + /** mergeable === null: GitHub hasn't recomputed yet, don't assert either way */ + mergeUnknown: boolean; /** base isn't main/master: lands with its parent, gates "ready" */ dependent: boolean; - /** who holds the active dev/deploy block, if any */ - blockedBy: string | null; + /** additions/deletions absent from the wire: weight and size sorts are guesses */ + sizeKnown: boolean; + /** everyone holding an active dev/deploy block, oldest first */ + blockedBy: string[]; /** login from the QAing label: someone is already testing this */ qaingBy: string | null; externalBlock: boolean; @@ -146,7 +150,10 @@ export function derive( .filter(u => !crBy.includes(u) && u !== pull.user.login) ); - const blockSig = st.dev_block[0] ?? st.deploy_block[0]; + // a lifted block deactivates its signature, same as a stale CR stamp + const blockHolders = unique( + [...st.dev_block, ...st.deploy_block].filter(s => s.data.active).map(s => s.data.user.login) + ); const crDone = crHave >= st.cr_req; const qaDone = qaHave >= st.qa_req; @@ -156,7 +163,7 @@ export function derive( let status: Status; if (pull.draft) status = 'draft'; - else if (blockSig) status = 'blocked'; + else if (blockHolders.length) status = 'blocked'; else if (ci === 'failing') status = 'ci_red'; else if (!crDone && staleCr.length) status = 'needs_recr'; else if (!crDone) status = 'needs_cr'; @@ -168,6 +175,7 @@ export function derive( const created = Date.parse(pull.created_at) / 1000; const ageDays = Math.max(0, Math.floor((now - created) / 86400)); + const sizeKnown = pull.additions != null || pull.deletions != null; const size = (pull.additions ?? 0) + (pull.deletions ?? 0); const starved = status === 'needs_cr' && crHave === 0 && ageDays >= STARVE_DAYS; @@ -186,8 +194,10 @@ export function derive( starveScore: starved ? ageDays * Math.max(size, 1) : 0, weight: reviewWeight(pull), conflict, + mergeUnknown: pull.mergeable == null, dependent, - blockedBy: blockSig?.data.user.login ?? null, + sizeKnown, + blockedBy: blockHolders, qaingBy: label(LABELS.qaing)?.user ?? null, externalBlock: !!label(LABELS.externalBlock), cryo: !!label(LABELS.cryo), diff --git a/frontend-v2/src/prefs.ts b/frontend-v2/src/prefs.ts index e4c9b84e..35a98ee7 100644 --- a/frontend-v2/src/prefs.ts +++ b/frontend-v2/src/prefs.ts @@ -1,4 +1,5 @@ import { useCallback, useSyncExternalStore } from 'react'; +import { readStorage, writeStorage } from './storage'; import type { Team } from './types'; /** @@ -19,7 +20,7 @@ function loadScope(): Scope { return { repos: [], authors: [], - ...(JSON.parse(localStorage.getItem(SCOPE_KEY) ?? '{}') as object), + ...(JSON.parse(readStorage(SCOPE_KEY) ?? '{}') as object), }; } catch { return { repos: [], authors: [] }; @@ -39,7 +40,7 @@ export function useScope(): [Scope, (next: Scope) => void] { ); const setScope = useCallback((next: Scope) => { scope = next; - localStorage.setItem(SCOPE_KEY, JSON.stringify(next)); + writeStorage(SCOPE_KEY, JSON.stringify(next)); for (const fn of listeners) fn(); }, []); return [value, setScope]; @@ -54,8 +55,16 @@ export async function loadTeams(): Promise { try { const res = await fetch(`${import.meta.env.BASE_URL}teams.json`); if (!res.ok) return []; - const teams = (await res.json()) as Team[]; - return Array.isArray(teams) ? teams : []; + const teams: unknown = await res.json(); + if (!Array.isArray(teams)) return []; + // one malformed entry must not white-screen the whole app + return teams.filter( + (t): t is Team => + !!t && + typeof (t as Team).team === 'string' && + Array.isArray((t as Team).members) && + (t as Team).members.every(m => typeof m === 'string') + ); } catch { return []; } diff --git a/frontend-v2/src/storage.ts b/frontend-v2/src/storage.ts new file mode 100644 index 00000000..bd330df1 --- /dev/null +++ b/frontend-v2/src/storage.ts @@ -0,0 +1,20 @@ +/** + * localStorage that never throws: privacy modes and blocked-storage settings + * make every localStorage touch a potential SecurityError, and a preference + * is never worth a white screen. + */ +export function readStorage(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +export function writeStorage(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // storage blocked: the preference just doesn't persist + } +} diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts index 7d2dc15c..47c142eb 100644 --- a/frontend-v2/src/store.ts +++ b/frontend-v2/src/store.ts @@ -1,6 +1,7 @@ import { useSyncExternalStore } from 'react'; import { backend, type ConnectionState } from './backend/socket'; import { derive, type DerivedPull } from './model/status'; +import { readStorage, writeStorage } from './storage'; import type { PullData, RepoSpec } from './types'; /** @@ -16,7 +17,13 @@ export interface Snapshot { closed: PullData[]; me: string; connection: ConnectionState; - /** epoch secs of the last-seen marker captured at page load */ + /** first initialize payload has arrived: [] means empty, not loading */ + initialized: boolean; + /** the /token fetch failed: session expired or server down */ + authFailed: boolean; + /** epoch secs of the last payload from the server; 0 until one arrives */ + lastPayloadAt: number; + /** epoch secs of the last-seen marker (previous visit's departure) */ lastSeen: number; } @@ -26,11 +33,19 @@ const raw = new Map(); let repoSpecs: RepoSpec[] = []; let me = ''; let connection: ConnectionState = 'connecting'; +let initialized = false; +let authFailed = false; +let lastPayloadAt = 0; const listeners = new Set<() => void>(); -// Captured once per page load: everything updated after this shows as fresh. -const lastSeen = Number(localStorage.getItem(LAST_SEEN_KEY)) || Date.now() / 1000 - 6 * 3600; -localStorage.setItem(LAST_SEEN_KEY, String(Date.now() / 1000)); +// The marker advances when you LEAVE (pagehide / tab hidden), not when you +// arrive — an accidental reload must not erase "changed since yesterday". +const lastSeen = Number(readStorage(LAST_SEEN_KEY)) || Date.now() / 1000 - 6 * 3600; +const stampSeen = () => writeStorage(LAST_SEEN_KEY, String(Date.now() / 1000)); +window.addEventListener('pagehide', stampSeen); +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') stampSeen(); +}); let snapshot: Snapshot = { pulls: [], @@ -38,20 +53,40 @@ let snapshot: Snapshot = { closed: [], me, connection, + initialized, + authFailed, + lastPayloadAt, lastSeen, }; +// derive() is pure per (pull, spec): cache on reference identity so a +// pullChange for one pull doesn't rebuild 180 DerivedPull objects (and +// re-render 180 memoized rows). +const derived = new WeakMap(); +function deriveCached(pull: PullData, spec: RepoSpec | undefined): DerivedPull { + const hit = derived.get(pull); + if (hit && hit.spec === spec) return hit.value; + const value = derive(pull, spec); + derived.set(pull, { spec, value }); + return value; +} + function publish() { const specByName = new Map(repoSpecs.map(s => [s.name, s])); const all = [...raw.values()]; snapshot = { - pulls: all.filter(p => p.state === 'open').map(p => derive(p, specByName.get(p.repo))), + pulls: all.filter(p => p.state === 'open').map(p => deriveCached(p, specByName.get(p.repo))), closed: all .filter(p => p.state === 'closed') - .sort((a, b) => Date.parse(b.closed_at ?? '0') - Date.parse(a.closed_at ?? '0')), + .sort( + (a, b) => (Date.parse(b.closed_at ?? '') || 0) - (Date.parse(a.closed_at ?? '') || 0) + ), repoSpecs, me, connection, + initialized, + authFailed, + lastPayloadAt, lastSeen, }; for (const fn of listeners) fn(); @@ -71,23 +106,37 @@ let started = false; function start() { if (started) return; started = true; - void backend.whoami().then(user => { - me = user; - schedulePublish(); - }); + backend.whoami().then( + user => { + me = user; + authFailed = false; + schedulePublish(); + }, + () => { + authFailed = true; + schedulePublish(); + } + ); backend.onPulls(payload => { if ('pulls' in payload) { + // full snapshot: replace, don't merge, or pulls dropped while we + // were disconnected live on as ghosts + raw.clear(); repoSpecs = payload.repos; for (const p of payload.pulls) raw.set(`${p.repo}#${p.number}`, p); + initialized = true; } else { raw.set(`${payload.repo}#${payload.number}`, payload); } + lastPayloadAt = Date.now() / 1000; schedulePublish(); }); backend.onConnection(state => { connection = state; schedulePublish(); }); + // time-based derivations (iterating, "Nm ago") expire even on a quiet board + setInterval(schedulePublish, 60_000); } export function usePulldasher(): Snapshot { diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index dbc43739..81e656a5 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -23,7 +23,8 @@ function yourMove(p: DerivedPull): string | null { } function waitingOn(p: DerivedPull): string { - if (p.status === 'blocked' && p.blockedBy) return `held by ${p.blockedBy} — ask them to lift`; + if (p.status === 'blocked' && p.blockedBy.length) + return `blocked by ${p.blockedBy.join(', ')}, ask them to lift the block`; if (p.status === 'needs_recr' && p.recrBy.length) { const wait = p.headPushedAt ? ` (fix up ${ago(p.headPushedAt)})` : ''; return `waiting on ${p.recrBy.join(', ')}’s re-stamp${wait}`; From 799080f935bb4d37d7384bee218d6c06fce64e5f Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 17:26:09 -0700 Subject: [PATCH 013/381] Defuse the scope foot-guns the adversarial review found Four ways the saved scope could lie or trap the user, all verified against code by the review pass: - The team preset committed the intersection of the team's roster with authors who currently have open PRs: a quiet teammate was scoped out of tomorrow's board, and a team with zero open PRs intersected to [] which the full-selection collapse then stored as 'everything' - the opposite of the click's intent. The preset now commits the roster. - A persisted scope entry with no open PRs today (person left, repo removed) filtered everything but appeared nowhere in the popover: every checkbox read unchecked and nothing could be un-checked. Ghost entries now render with a 'no open PRs' note and stay uncheckable. - The clear-X was a role=button span nested inside the trigger button (invalid ARIA, Enter-only). It's a real sibling button now. - The popover closes on Escape with focus restored to the trigger, and the trigger exposes aria-haspopup/aria-expanded. The hover-only 'only' shortcut is focusable (opacity reveal, not visibility). Also: the changed-since count is computed from the scoped pool instead of all pulls, so the banner can no longer promise 12 changed PRs and deliver zero after the click; and the Board lens receives humans plus a bots fold instead of raw scoped pulls, so scoping to one person no longer scatters dependabot rows through the columns (bots bypass the people filter on purpose - dependency bumps need review no matter whose work you follow - but they belong in a fold, not the lanes). Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 22 +++++-- frontend-v2/src/components/Scope.tsx | 97 +++++++++++++++++----------- frontend-v2/src/views/Board.tsx | 46 +++++++++---- 3 files changed, 111 insertions(+), 54 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 2e129e79..1bf417f3 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -67,7 +67,10 @@ export function App() { [repoSpecs] ); - const scoped = useMemo(() => { + // scope/query applied, but NOT the changed-only toggle: the changed count + // must describe the pool the toggle would narrow, or the banner promises + // rows the click doesn't deliver + const inScope = useMemo(() => { let out = pulls; // v1 conventions: Cryogenic-Storage pulls and hideByDefault repos stay // off the board unless asked for (or the scope names the repo). @@ -76,6 +79,8 @@ export function App() { p => !p.cryo && (!hiddenRepos.has(p.data.repo) || scope.repos.includes(p.data.repo)) ); if (scope.repos.length) out = out.filter(p => scope.repos.includes(p.data.repo)); + // bots bypass the people filter on purpose: dependency bumps need review + // no matter whose work you follow (they land in the bots fold, not lanes) if (scope.authors.length) out = out.filter(p => isBot(p) || scope.authors.includes(p.data.user.login)); if (query) { @@ -89,13 +94,20 @@ export function App() { ) ); } - if (onlyChanged) out = out.filter(p => Date.parse(p.data.updated_at) / 1000 > lastSeen); return out; - }, [pulls, scope, query, onlyChanged, lastSeen, showHidden, hiddenRepos]); + }, [pulls, scope, query, showHidden, hiddenRepos]); + + const scoped = useMemo( + () => + onlyChanged + ? inScope.filter(p => Date.parse(p.data.updated_at) / 1000 > lastSeen) + : inScope, + [inScope, onlyChanged, lastSeen] + ); const humans = scoped.filter(p => !isBot(p)); const bots = scoped.filter(isBot); - const changedCount = pulls.filter( + const changedCount = inScope.filter( p => !isBot(p) && Date.parse(p.data.updated_at) / 1000 > lastSeen ).length; const hiddenCount = pulls.filter( @@ -289,7 +301,7 @@ export function App() { {initialized && lens === 'teams' && ( )} - {initialized && lens === 'board' && } + {initialized && lens === 'board' && } ); diff --git a/frontend-v2/src/components/Scope.tsx b/frontend-v2/src/components/Scope.tsx index 317a7b5b..019dbc59 100644 --- a/frontend-v2/src/components/Scope.tsx +++ b/frontend-v2/src/components/Scope.tsx @@ -13,14 +13,24 @@ export function ScopeControl({ pulls, teams }: { pulls: DerivedPull[]; teams: Te const [scope, setScope] = useScope(); const [open, setOpen] = useState(false); const ref = useRef(null); + const triggerRef = useRef(null); useEffect(() => { if (!open) return; - const close = (e: MouseEvent) => { + const clickAway = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); }; - document.addEventListener('click', close); - return () => document.removeEventListener('click', close); + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + setOpen(false); + triggerRef.current?.focus(); + }; + document.addEventListener('click', clickAway); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('click', clickAway); + document.removeEventListener('keydown', onKey); + }; }, [open]); const repoCounts = new Map(); @@ -29,21 +39,32 @@ export function ScopeControl({ pulls, teams }: { pulls: DerivedPull[]; teams: Te repoCounts.set(p.data.repo, (repoCounts.get(p.data.repo) ?? 0) + 1); authorCounts.set(p.data.user.login, (authorCounts.get(p.data.user.login) ?? 0) + 1); } + // A saved scope can name people or repos with no open PRs today. They + // still filter, so they must stay visible (and uncheckable) or the scope + // becomes impossible to undo except by clearing everything. + for (const name of scope.authors) { + if (!authorCounts.has(name)) authorCounts.set(name, 0); + } + for (const name of scope.repos) { + if (!repoCounts.has(name)) repoCounts.set(name, 0); + } const repos = [...repoCounts.entries()].sort((a, b) => b[1] - a[1]); const authors = [...authorCounts.entries()].sort((a, b) => b[1] - a[1]); const scopedCount = scope.repos.length + scope.authors.length; const label = scopedCount ? `Scope: ${[ - scope.repos.length && `${scope.repos.length} repos`, - scope.authors.length && `${scope.authors.length} people`, + scope.repos.length && + `${scope.repos.length} ${scope.repos.length === 1 ? 'repo' : 'repos'}`, + scope.authors.length && + `${scope.authors.length} ${scope.authors.length === 1 ? 'person' : 'people'}`, ] .filter(Boolean) .join(' · ')}` : 'Scope: everything'; const commit = (key: keyof Scope, next: string[], all: string[]) => - setScope({ ...scope, [key]: next.length === all.length ? [] : next }); + setScope({ ...scope, [key]: all.length && next.length === all.length ? [] : next }); const toggle = (key: keyof Scope, name: string, all: string[]) => { const cur = scope[key].length ? [...scope[key]] : [...all]; @@ -57,7 +78,7 @@ export function ScopeControl({ pulls, teams }: { pulls: DerivedPull[]; teams: Te list.map(([name, count]) => ( )); return ( - + + {scopedCount > 0 && ( + + )} {open && ( - + + )} {hiddenCount > 0 && ( + )} + + ); +} + /** One folded line in "the rest of the board": count + hint, rows on demand. */ export function Fold({ dot, diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index 81e656a5..e52099c6 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -2,7 +2,7 @@ import type { DerivedPull } from '../model/status'; import type { PullData } from '../types'; import { ago } from '../format'; import { EmptyState } from '../components/bits'; -import { Fold, Lane, RestGroup } from '../components/Lane'; +import { Fold, Lane, RestGroup, Truncated } from '../components/Lane'; import { Row, type RowOptions } from '../components/Row'; import { ClosedRow } from '../components/ClosedRow'; @@ -108,9 +108,11 @@ export function MyWork({ label="shipped in the last 14 days" hint="nice work" > - {shipped.slice(0, 30).map(p => ( - - ))} + + {shipped.map(p => ( + + ))} + )} diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 230fc147..2282a341 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -1,9 +1,8 @@ -import { Fragment } from 'react'; -import { weightRank, type DerivedPull } from '../model/status'; +import type { DerivedPull } from '../model/status'; import { crSort } from '../model/sort'; import type { PullData } from '../types'; import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; -import { DividerLine, Fold, Lane, RestGroup } from '../components/Lane'; +import { Fold, Lane, RestGroup, Truncated } from '../components/Lane'; import { Row, type RowOptions } from '../components/Row'; import { ClosedRow } from '../components/ClosedRow'; @@ -36,10 +35,6 @@ export function Review({ const crPool = others.filter(p => p.status === 'needs_cr' && !p.crBy.includes(me)); const aged = crPool.filter(p => p.starved).sort((a, b) => b.starveScore - a.starveScore); const queue = crSort(crPool.filter(p => !p.starved)); - const firstHeavy = queue.findIndex(p => weightRank(p.weight) >= 2); - - // 3. Aging: its own visible lane — the fairness debt, not a footnote. - const agingShown = aged.slice(0, 3); // 4. Needs QA: a real lane again (v1's QA column earned it); QAing-label // rows sort last since someone is already on them. @@ -49,10 +44,13 @@ export function Review({ const stamped = others.filter(p => p.status === 'needs_cr' && p.crBy.includes(me)); const rowKey = (p: DerivedPull) => `${p.data.repo}#${p.data.number}`; - const foldRows = (list: DerivedPull[], o: Partial = {}) => - list - .slice(0, 30) - .map(p => ); + const foldRows = (list: DerivedPull[], o: Partial = {}) => ( + + {list.map(p => ( + + ))} + + ); const empty = !actNow.length && !crPool.length && !needsQa.length && !others.length; if (empty) { @@ -73,38 +71,20 @@ export function Review({ cap={8} opts={{ ...opts, pips: 'cr' }} /> - {crPool.length > 0 && ( - - {queue.slice(0, 9).map((p, i) => ( - - {i === firstHeavy && firstHeavy > 0 && ( - - )} - - - ))} - {queue.length > 9 && ( - - )} - - )} - {aged.length > 0 && ( - - {agingShown.map(p => ( - - ))} - {aged.length > 3 && } - - )} + + - {closed.slice(0, 30).map(p => ( - - ))} + + {closed.map(p => ( + + ))} + From 7d7ba0e25df47b1f001d225d90729948295ebb6b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 18:11:09 -0700 Subject: [PATCH 015/381] Simplify the row to what a human can decode, and add a legend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review panel counted ~16 distinct visual encodings per row and independently converged on the same cuts: - Heat bars: five unlabeled 2px bars encoding age-in-weeks, illegible without the hover tooltip. Replaced by plain 'Nd' age text - shorter than the bars and needs no decoding. - Raw +/- diff numbers: the weight chip already encodes size, bucketed; showing its inputs beside it stated one fact twice. - The iterating dim: a whole row at 60% opacity for a reason only a tiny flag's tooltip explained, and the trigger (any updated_at change) fired on reviewer comments too - 'author is iterating' was overpromised. The flag stays, honestly reworded; the dimming and the noDim plumbing threaded through every view are gone. - The fresh-row marker was a 3px colored border-left, the exact side-stripe pattern DESIGN.md bans. It's a small leading dot now. New: a ? legend popover in the header decoding the invented vocabulary (stamp, pips, the slashed re-CR pip, weight chips, QAing, blocks, the fresh dot) - the review's new-hire walkthrough hit a wall on 'stamp' having no definition anywhere on screen. Row fixes that ride along: status labels stop lying ('Ready to merge', 'Only CI left' instead of 'CI running' which also described half the review queue, 'Needs re-CR' instead of the imperative 'Re-stamp'); avatars use OKLCH so yellow-green logins keep legible initials (the old hsl(h 45% 45%) dipped to 2.6:1); the unicode ⎘/↻ glyphs (tofu on stock Windows) are inline SVGs with aria-labels; pips announce 'CR 1 of 2' to screen readers instead of silence; the weight chip shows '?' when the wire has no diff sizes instead of calling everything XS; row actions reveal with opacity instead of visibility:hidden, which had removed them from the tab order entirely. Dark-mode ink-2/ink-3 lift a step to clear AA on --muted. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 2 + frontend-v2/src/components/Legend.tsx | 95 +++++++++++++++++++++++++++ frontend-v2/src/components/Row.tsx | 67 +++++++++++-------- frontend-v2/src/components/bits.tsx | 57 ++++++++-------- frontend-v2/src/styles.css | 12 ++-- frontend-v2/src/views/Board.tsx | 2 +- frontend-v2/src/views/MyWork.tsx | 4 +- frontend-v2/src/views/People.tsx | 4 +- frontend-v2/src/views/Review.tsx | 4 +- frontend-v2/src/views/Teams.tsx | 2 +- 10 files changed, 178 insertions(+), 71 deletions(-) create mode 100644 frontend-v2/src/components/Legend.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 5ed55a48..d1f0e66f 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -4,6 +4,7 @@ import { STATUS_ORDER, type DerivedPull } from './model/status'; import type { Team } from './types'; import { usePulldasher } from './store'; import { loadTeams, useScope } from './prefs'; +import { Legend } from './components/Legend'; import { ScopeControl } from './components/Scope'; import { STATUS_LABEL } from './components/bits'; import type { RowOptions } from './components/Row'; @@ -233,6 +234,7 @@ export function App() { )} {bots.length} bot PRs +
diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx new file mode 100644 index 00000000..fb686741 --- /dev/null +++ b/frontend-v2/src/components/Legend.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * The one-stop decoder for the board's invented vocabulary. New hires can't + * learn "stamp" or the slashed pip from hover titles alone — this is the + * visible answer the review pass found missing. + */ +export function Legend() { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const clickAway = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + setOpen(false); + triggerRef.current?.focus(); + }; + document.addEventListener('click', clickAway); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('click', clickAway); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + const item = (term: React.ReactNode, def: string) => ( +
+ {term} + {def} +
+ ); + + return ( + + + {open && ( + + Reading the board + {item( + stamp, + 'a CR or QA sign-off, left as a comment on the PR' + )} + {item( + <> + + , + 'sign-offs: empty = still needed, filled = given' + )} + {item( + , + 'a push invalidated the stamp: it needs re-CR' + )} + {item( + M, + 'estimated review effort (XS to XL), from diff size' + )} + {item( + QAing, + 'someone claimed QA by adding the QAing label on GitHub' + )} + {item( + iterating, + 'changed in the last 30 minutes, may still be moving' + )} + {item( + Blocked, + 'a dev/deploy block signature: ask the holder to lift it' + )} + {item(, 'changed since your last look')} + {item(, 'Cryogenic Storage PRs and quiet repos, hidden by default')} + + )} + + ); +} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 90e3f683..66380590 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -3,16 +3,14 @@ import type { DerivedPull } from '../model/status'; import { isIterating } from '../model/status'; import { ago, githubUrl, shortRepo } from '../format'; import { refreshPull } from '../store'; -import { Avatar, Heat, Pips, StatusBadge, WeightChip } from './bits'; +import { Avatar, Pips, StatusBadge, WeightChip } from './bits'; export interface RowOptions { /** hide the status badge when the lane already says it */ badge?: boolean; pips?: 'cr' | 'qa' | 'both' | 'none'; - /** show the waiting-Nd flag on starved pulls */ + /** show the open-Nd flag on starved pulls */ aging?: boolean; - /** skip the cooldown dimming (own PRs, folded sections) */ - noDim?: boolean; me: string; lastSeen: number; onPerson?: (login: string) => void; @@ -22,14 +20,14 @@ function cue(p: DerivedPull, me: string): string | null { const d = p.data; if (p.status === 'needs_recr' && p.recrBy.length) { const who = p.recrBy.includes(me) ? 'you' : p.recrBy.join(', '); - const wait = p.headPushedAt ? ` · fix waiting ${ago(p.headPushedAt)}` : ''; + const wait = p.headPushedAt ? ` · fix pushed ${ago(p.headPushedAt)} ago` : ''; return `CR’d by ${who}${wait}`; } if (p.status === 'ready') - return `CR✓ QA✓ green: ${d.user.login === me ? 'your' : `${d.user.login}’s`} merge button`; + return `signed off and green, ${d.user.login === me ? 'you' : d.user.login} can merge`; if (p.status === 'needs_qa') - return p.qaingBy ? `${p.qaingBy} is QAing` : 'CR✓, author drives QA'; - if (p.status === 'ci_pending') return 'signed off, waiting on CI'; + return p.qaingBy ? `${p.qaingBy} is QAing` : 'CR done, needs a QA stamp'; + if (p.status === 'ci_pending') return 'signed off, only CI left'; if (p.status === 'blocked' && p.blockedBy.length) return `blocked by ${p.blockedBy.join(', ')}`; return null; } @@ -67,21 +65,25 @@ function WarnFlags({ pull }: { pull: DerivedPull }) { )} {pull.qaingBy && pull.status === 'needs_qa' && ( - - ◉ {pull.qaingBy} + + QAing: {pull.qaingBy} )} ); } -/** Hover actions: re-fetch from GitHub, copy the branch name. */ +/** Hover/focus actions: copy the branch name, re-fetch from GitHub. */ function RowActions({ pull }: { pull: DerivedPull }) { const [copied, setCopied] = useState(false); return ( ); @@ -106,7 +117,6 @@ function RowActions({ pull }: { pull: DerivedPull }) { export function Row({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const d = pull.data; - const iterating = isIterating(d); const fresh = Date.parse(d.updated_at) / 1000 > opts.lastSeen; const pips = opts.pips ?? 'both'; const showWeight = ['needs_cr', 'needs_recr'].includes(pull.status); @@ -115,8 +125,9 @@ export function Row({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { return (
+ {fresh && } {opts.badge === false ? null : } @@ -133,15 +144,16 @@ export function Row({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { - {iterating && ( - + {isIterating(d) && ( + iterating )} - {opts.aging && pull.starved && waiting {pull.ageDays}d} + {opts.aging && pull.starved && ( + + open {pull.ageDays}d + + )} {line && ( @@ -149,18 +161,19 @@ export function Row({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { )} - {showWeight && } + {showWeight && } {(pips === 'cr' || pips === 'both') && ( )} {(pips === 'qa' || pips === 'both') && ( )} - - +{d.additions ?? '?'}{' '} - −{d.deletions ?? '?'} + + {pull.ageDays}d -
); diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 5d60a095..83fbf5c1 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -2,9 +2,11 @@ import type { Status, Weight } from '../model/status'; import { loginHue } from '../format'; export const STATUS_LABEL: Record = { - ready: 'Ready', - ci_pending: 'CI running', - needs_recr: 'Re-stamp', + ready: 'Ready to merge', + // only at the ready gate: fully signed off, nothing left but a green build. + // 'CI running' would lie — a needs-CR pull can have CI running too. + ci_pending: 'Only CI left', + needs_recr: 'Needs re-CR', needs_qa: 'Needs QA', needs_cr: 'Needs CR', blocked: 'Blocked', @@ -47,8 +49,10 @@ export function Avatar({ size?: number; onClick?: (login: string) => void; }) { + // OKLCH holds perceived lightness constant across the hue wheel — the + // old hsl(h 45% 45%) made yellow-green logins illegible under white text const style = { - background: `hsl(${loginHue(login)} 45% 45%)`, + background: `oklch(0.48 0.09 ${loginHue(login)})`, width: size, height: size, fontSize: Math.round(size * 0.42), @@ -60,7 +64,8 @@ export function Avatar({ type="button" className={`${cls} cursor-pointer border-0 p-0 hover:scale-115`} style={style} - title={login} + aria-label={`${login}: view their PRs`} + title={`${login} · view their PRs`} onClick={() => onClick(login)} > {login.slice(0, 2)} @@ -72,11 +77,15 @@ export function Avatar({ ); } -export function WeightChip({ weight }: { weight: Weight }) { - return ( - +export function WeightChip({ weight, known = true }: { weight: Weight; known?: boolean }) { + return known ? ( + {weight} + ) : ( + + ? + ); } @@ -92,36 +101,26 @@ export function Pips({ stale?: boolean; }) { const total = Math.max(req, have); + const staleNote = stale && have === 0; return ( - - {label} - {have === 0 && stale ? ( - + + + {label} + + {staleNote ? ( + ) : ( Array.from({ length: total }, (_, i) => ( - + )) )} ); } -export function Heat({ days }: { days: number }) { - const n = Math.min(5, Math.ceil((days + 1) / 7)); - const tone = days >= 28 ? 'heat-hot' : days >= 14 ? 'heat-warm' : ''; - return ( - - {Array.from({ length: 5 }, (_, i) => ( - = n ? { opacity: 0.25 } : undefined} - /> - ))} - - ); -} - export function EmptyState({ title, sub }: { title: string; sub: string }) { return (
diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 1b146ea6..9e65d4e5 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -12,7 +12,7 @@ --ring: #99a1af; --ink: #030712; --ink-2: #4b5563; - --ink-3: #6b7280; + --ink-3: #5d6673; --brand: #0071ce; --brand-50: #e0f1ff; --brand-100: #cce7ff; @@ -34,9 +34,6 @@ --warn: #d97706; --bad: #dc2626; --violet: #9333ea; - --heat0: #d1d5dc; - --heat1: #fe9a00; - --heat2: #e7000b; --ease-quint: cubic-bezier(0.22, 1, 0.36, 1); --ease-quart: cubic-bezier(0.25, 1, 0.5, 1); } @@ -48,9 +45,11 @@ --secondary: #364153; --border: #364153; --ring: #6a7282; + /* ink-2/ink-3 sit a step lighter than the gray ramp suggests: they carry + 11-12px content (repo#, counts, cues) and must clear AA on --muted too */ --ink: #f9fafb; - --ink-2: #99a1af; - --ink-3: #6a7282; + --ink-2: #b6bcc7; + --ink-3: #99a1af; --brand: #4da3e8; --brand-50: #0d2b47; --brand-100: #123a5f; @@ -228,7 +227,6 @@ details[open] > .fold-body { } } .row-fresh { - border-left-color: var(--brand) !important; animation: fresh-flash 1.4s var(--ease-quart) 300ms backwards; } diff --git a/frontend-v2/src/views/Board.tsx b/frontend-v2/src/views/Board.tsx index 81c9ad08..fc08221d 100644 --- a/frontend-v2/src/views/Board.tsx +++ b/frontend-v2/src/views/Board.tsx @@ -60,7 +60,7 @@ function Column({ ))}
diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index e52099c6..a8d86a8b 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -68,7 +68,7 @@ export function MyWork({ {yourMove(p)}
- +
))} @@ -87,7 +87,7 @@ export function MyWork({ {waiting.map(p => (
- + {waitingOn(p)} diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx index a9e512cc..93d5eaec 100644 --- a/frontend-v2/src/views/People.tsx +++ b/frontend-v2/src/views/People.tsx @@ -119,7 +119,7 @@ export function People({ ))} @@ -133,7 +133,7 @@ export function People({ ))} diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 2282a341..aa57867b 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -47,7 +47,7 @@ export function Review({ const foldRows = (list: DerivedPull[], o: Partial = {}) => ( {list.map(p => ( - + ))} ); @@ -90,7 +90,7 @@ export function Review({ sub="CR done — grab one, or nudge the author" pulls={needsQa} cap={6} - opts={{ ...opts, pips: 'qa', noDim: true }} + opts={{ ...opts, pips: 'qa' }} /> ))} From 99636ab3f582163b21ab75ff95dd12272178e50d Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 18:14:57 -0700 Subject: [PATCH 016/381] Act now means YOUR move; merge the Teams tab into People Three reviewers and the persona walkthroughs agreed on the same two structural problems, and both changes follow the calls made on them: Act now was padded with other people's jobs. It appended everyone else's owed re-stamps and every ready PR - whose own row cue admitted 'alice can merge'. A reviewer who owed nothing opened the app to a top lane of 8 rows, none theirs, and had to read every cue line to learn that. Act now is now only re-stamps you personally owe: always truthfully 'minutes, and yours'. Others' stale re-stamps join the Review queue (a fresh CR from anyone counts there; the slashed pip still marks them) and ready-to-merge becomes a counted fold in the rest group, since the merge button belongs to the author. The Teams tab was structurally a clone of People minus People's unique data (owed re-stamps), and it composed confusingly with the Scope team preset. One directory tab now: pick a person or a team from the same picker, same board below. Fixes ride along: - An explicit pick always wins, even with zero open PRs - the old view silently snapped to the first non-empty subject mid-standup. - The person picker was hard-capped at 24 logins with no overflow at ~30 engineers; it now ends in a working '+ N more'. - The picker counts (unscoped) and the lanes (scoped) disagreed with no explanation; the profile card now says 'scope hides N more'. - PRs you already stamped no longer sit in 'you can review' - they fold separately, same vocabulary as the Review tab. My work rides along with its copy honesty pass: 'waiting on first CR, 12d old' misread PR age as queue time and ignored partial progress (now 'no CR yet, open 12d' / '1 of 2 CRs, open 12d'); 'fix up 3h' parsed as a command (now 'fix pushed 3h ago'); 'QA it' told authors to stamp their own QA (now 'Find a QA-er'); both lanes sort oldest first; and the empty state stops telling people to go work. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 18 +-- frontend-v2/src/views/MyWork.tsx | 22 ++-- frontend-v2/src/views/People.tsx | 202 ++++++++++++++++++++++--------- frontend-v2/src/views/Review.tsx | 54 ++++++--- frontend-v2/src/views/Teams.tsx | 125 ------------------- 5 files changed, 207 insertions(+), 214 deletions(-) delete mode 100644 frontend-v2/src/views/Teams.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index d1f0e66f..385388c1 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -11,10 +11,9 @@ import type { RowOptions } from './components/Row'; import { Review } from './views/Review'; import { MyWork } from './views/MyWork'; import { People } from './views/People'; -import { Teams } from './views/Teams'; import { Board } from './views/Board'; -type Lens = 'review' | 'mine' | 'people' | 'teams' | 'board'; +type Lens = 'review' | 'mine' | 'people' | 'board'; const BOT_LOGINS = new Set(['ifixit-systems']); const isBot = (p: DerivedPull) => @@ -124,6 +123,7 @@ export function App() { lastSeen, onPerson: login => { setPerson(login); + setTeam(null); setLens('people'); }, }; @@ -195,7 +195,6 @@ export function App() { {tab('review', 'Review')} {tab('mine', 'My work', mineCount)} {tab('people', 'People')} - {tab('teams', 'Teams')} {tab('board', 'Board')} @@ -307,13 +306,18 @@ export function App() { allPulls={pulls.filter(p => !isBot(p))} teams={teams} person={person} - onPerson={setPerson} + team={team} + onPerson={login => { + setPerson(login); + setTeam(null); + }} + onTeam={name => { + setTeam(name); + setPerson(null); + }} opts={rowOpts} /> )} - {initialized && lens === 'teams' && ( - - )} {initialized && lens === 'board' && } diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index a8d86a8b..8330987c 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -17,7 +17,7 @@ function yourMove(p: DerivedPull): string | null { if (p.status === 'ready') return 'Merge it'; if (p.status === 'ci_red') return 'Fix CI'; if (p.conflict) return 'Resolve conflicts'; - if (p.status === 'needs_qa' && !p.qaingBy) return 'QA it'; + if (p.status === 'needs_qa' && !p.qaingBy) return 'Find a QA-er'; if (p.status === 'draft') return 'Finish the draft'; return null; } @@ -26,10 +26,13 @@ function waitingOn(p: DerivedPull): string { if (p.status === 'blocked' && p.blockedBy.length) return `blocked by ${p.blockedBy.join(', ')}, ask them to lift the block`; if (p.status === 'needs_recr' && p.recrBy.length) { - const wait = p.headPushedAt ? ` (fix up ${ago(p.headPushedAt)})` : ''; + const wait = p.headPushedAt ? ` (fix pushed ${ago(p.headPushedAt)} ago)` : ''; return `waiting on ${p.recrBy.join(', ')}’s re-stamp${wait}`; } - if (p.status === 'needs_cr') return `waiting on first CR, ${p.ageDays}d old`; + if (p.status === 'needs_cr') + return p.crHave > 0 + ? `${p.crHave} of ${p.data.status.cr_req} CRs, open ${p.ageDays}d` + : `no CR yet, open ${p.ageDays}d`; if (p.status === 'needs_qa' && p.qaingBy) return `${p.qaingBy} is QAing now`; if (p.status === 'ci_pending') return 'waiting on CI'; return 'waiting'; @@ -46,15 +49,16 @@ export function MyWork({ }) { const me = opts.me; const mine = pulls.filter(p => p.data.user.login === me); - const move = mine.filter(p => yourMove(p) !== null); - const waiting = mine.filter(p => yourMove(p) === null); + const byUrgency = (a: DerivedPull, b: DerivedPull) => b.ageDays - a.ageDays; + const move = mine.filter(p => yourMove(p) !== null).sort(byUrgency); + const waiting = mine.filter(p => yourMove(p) === null).sort(byUrgency); const shipped = closed.filter(p => p.user.login === me); if (!mine.length && !shipped.length) { return ( ); } @@ -74,7 +78,7 @@ export function MyWork({ ))} {!move.length && (
- Nothing needs you right now — it’s all in other people’s hands below. + Nothing needs you right now. Everything below is waiting on someone else.
)} @@ -105,8 +109,8 @@ export function MyWork({ {shipped.map(p => ( diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx index 93d5eaec..a4d65a93 100644 --- a/frontend-v2/src/views/People.tsx +++ b/frontend-v2/src/views/People.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { STATUS_ORDER, type DerivedPull } from '../model/status'; import type { Team } from '../types'; import { Avatar } from '../components/bits'; @@ -5,12 +6,19 @@ import { Fold, Lane, RestGroup } from '../components/Lane'; import { Row, type RowOptions } from '../components/Row'; import { crSort } from '../model/sort'; +/** + * The directory: one tab, pick a person OR a team, same board below. + * (Teams used to be its own tab — structurally a clone of this view minus + * the person-only data, so the two merged.) + */ export function People({ pulls, allPulls, teams, person, + team, onPerson, + onTeam, opts, }: { /** scoped pool (what the lanes show) */ @@ -19,9 +27,12 @@ export function People({ allPulls: DerivedPull[]; teams: Team[]; person: string | null; + team: string | null; onPerson: (login: string) => void; + onTeam: (team: string) => void; opts: RowOptions; }) { + const [allPeople, setAllPeople] = useState(false); const counts = new Map(); for (const p of allPulls) counts.set(p.data.user.login, (counts.get(p.data.user.login) ?? 0) + 1); @@ -31,53 +42,128 @@ export function People({ const logins = [...new Set([...counts.keys(), ...owes.keys()])].sort( (a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0) ); - const selected = person && logins.includes(person) ? person : logins[0]; - if (!selected) return null; - const theirs = pulls.filter(p => p.data.user.login === selected); - const owed = owes.get(selected) ?? []; + // an explicit pick always wins, even with zero open PRs — silently + // showing someone else's board mid-conversation is worse than an empty one + const selectedTeam = team && teams.some(t => t.team === team) ? team : null; + const selectedPerson = selectedTeam ? null : (person ?? logins[0]); + if (!selectedPerson && !selectedTeam) return null; + + const members = selectedTeam + ? (teams.find(t => t.team === selectedTeam)?.members ?? []) + : [selectedPerson as string]; + const isSubject = (p: DerivedPull) => members.includes(p.data.user.login); + + const theirs = pulls.filter(isSubject); + const theirsUnscoped = allPulls.filter(isSubject); + const owed = selectedPerson ? (owes.get(selectedPerson) ?? []) : []; const reviewable = crSort( theirs.filter( - p => ['needs_cr', 'needs_recr'].includes(p.status) && p.data.user.login !== opts.me + p => + ['needs_cr', 'needs_recr'].includes(p.status) && + p.data.user.login !== opts.me && + !p.crBy.includes(opts.me) && + !p.recrBy.includes(opts.me) ) ); + const mine = theirs.filter( + p => + ['needs_cr', 'needs_recr'].includes(p.status) && + p.data.user.login !== opts.me && + (p.crBy.includes(opts.me) || p.recrBy.includes(opts.me)) + ); const rest = theirs - .filter(p => !reviewable.includes(p)) + .filter(p => !reviewable.includes(p) && !mine.includes(p)) .sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status)); - const team = teams.find(t => t.members.includes(selected))?.team; + const memberTeam = selectedPerson + ? teams.find(t => t.members.includes(selectedPerson))?.team + : null; const shipping = theirs.filter(p => ['ready', 'needs_qa'].includes(p.status)).length; + const scopeHides = theirsUnscoped.length - theirs.length; + + const chip = (key: string, label: React.ReactNode, active: boolean, onPick: () => void) => ( + + ); + + const shownLogins = allPeople ? logins : logins.slice(0, 24); return ( <> + {teams.length > 0 && ( +
+ {teams.map(t => + chip( + `team:${t.team}`, + <> + {t.team} + + {allPulls.filter(p => t.members.includes(p.data.user.login)).length} + + , + t.team === selectedTeam, + () => onTeam(t.team) + ) + )} +
+ )}
- {logins.slice(0, 24).map(login => ( + {shownLogins.map(login => + chip( + login, + <> + + {login} + + {counts.get(login) ?? 0} + + , + login === selectedPerson, + () => onPerson(login) + ) + )} + {!allPeople && logins.length > 24 && ( - ))} + )}
- + {selectedPerson ? ( + + ) : ( + + {members.slice(0, 6).map(m => ( + + ))} + + )} - {selected} + + {selectedPerson ?? selectedTeam} +
- {team ? `${team} · ` : ''} - {theirs.length} open PRs + {memberTeam ? `${memberTeam} · ` : ''} + {selectedTeam ? `${members.length} members · ` : ''} + {theirs.length} open {theirs.length === 1 ? 'PR' : 'PRs'} + {scopeHides > 0 && ( + · scope hides {scopeHides} more + )}
@@ -89,54 +175,60 @@ export function People({ {shipping} - close to shipping - - - - {owed.length} - - re-stamps owed + ready or in QA + {selectedPerson && ( + + + {owed.length} + + {owed.length === 1 ? 're-stamp owed' : 're-stamps owed'} + + )}
- {(rest.length > 0 || owed.length > 0) && ( + {(rest.length > 0 || owed.length > 0 || mine.length > 0) && ( - {rest.map(p => ( - + {mine.map(p => ( + ))} - {owed.map(p => ( - + {rest.map(p => ( + ))} + {selectedPerson && ( + + {owed.map(p => ( + + ))} + + )} )} diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index aa57867b..1ab677eb 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -24,18 +24,27 @@ export function Review({ const me = opts.me; const others = pulls.filter(p => p.data.user.login !== me); - // 1. Act now: re-stamps (yours first), then merge nudges. Minutes each. - const actNow = [ - ...others.filter(p => p.status === 'needs_recr' && p.recrBy.includes(me)), - ...others.filter(p => p.status === 'needs_recr' && !p.recrBy.includes(me)), - ...others.filter(p => p.status === 'ready'), - ]; + // 1. Act now: ONLY re-stamps you personally owe. The review pass found the + // old version padded with other people's jobs (their re-stamps, other + // authors' merge buttons) — three mornings of that and the lane reads + // as noise. Now it's always truthfully "minutes, and yours". + const actNow = others.filter(p => p.status === 'needs_recr' && p.recrBy.includes(me)); - // 2. Review queue: lightest to heaviest. - const crPool = others.filter(p => p.status === 'needs_cr' && !p.crBy.includes(me)); + // 2. Review queue: lightest first. Includes pulls waiting on someone + // else's re-stamp — a fresh CR from you counts there too (the stale + // pip marks them). + const crPool = others.filter( + p => + (p.status === 'needs_cr' && !p.crBy.includes(me)) || + (p.status === 'needs_recr' && !p.recrBy.includes(me)) + ); const aged = crPool.filter(p => p.starved).sort((a, b) => b.starveScore - a.starveScore); const queue = crSort(crPool.filter(p => !p.starved)); + // Ready-to-merge is the author's button, not the reviewer's job: a count + // in the rest group, not a lane at the top. + const ready = others.filter(p => p.status === 'ready'); + // 4. Needs QA: a real lane again (v1's QA column earned it); QAing-label // rows sort last since someone is already on them. const needsQa = [...others.filter(p => p.status === 'needs_qa')].sort( @@ -52,12 +61,13 @@ export function Review({
); - const empty = !actNow.length && !crPool.length && !needsQa.length && !others.length; + // bots/shipped stay reachable even when no human PRs need review + const empty = !others.length && !bots.length && !closed.length; if (empty) { return ( ); } @@ -66,7 +76,7 @@ export function Review({ <> - + + + {foldRows(ready, { pips: 'none' })} + {foldRows(stamped, { pips: 'cr', badge: false })} @@ -105,7 +123,7 @@ export function Review({ dot={STATUS_DOT.blocked} count={others.filter(p => p.status === 'blocked').length} label="blocked" - hint="find who holds the block" + hint="each row names the holder" > {foldRows(others.filter(p => p.status === 'blocked'))}
@@ -121,7 +139,7 @@ export function Review({ dot={STATUS_DOT.ci_red} count={others.filter(p => p.status === 'ci_red').length} label="CI red" - hint="authors’ move" + hint="usually the author's fix" > {foldRows(others.filter(p => p.status === 'ci_red'))} @@ -137,7 +155,7 @@ export function Review({ dot="var(--ink-3)" count={bots.length} label="bot PRs" - hint="batch in one sitting or automerge" + hint="dependency bumps, review in a batch" > {foldRows(bots)} diff --git a/frontend-v2/src/views/Teams.tsx b/frontend-v2/src/views/Teams.tsx deleted file mode 100644 index 231e37d0..00000000 --- a/frontend-v2/src/views/Teams.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { STATUS_ORDER, type DerivedPull } from '../model/status'; -import type { Team } from '../types'; -import { Avatar, EmptyState, STATUS_LABEL } from '../components/bits'; -import { Fold, Lane, RestGroup } from '../components/Lane'; -import { Row, type RowOptions } from '../components/Row'; -import { crSort } from '../model/sort'; - -export function Teams({ - pulls, - teams, - team, - onTeam, - opts, -}: { - pulls: DerivedPull[]; - teams: Team[]; - team: string | null; - onTeam: (team: string) => void; - opts: RowOptions; -}) { - if (!teams.length) { - return ( - - ); - } - - const memberOf = (login: string) => teams.find(t => t.members.includes(login))?.team; - const byTeam = new Map(); - for (const t of teams) byTeam.set(t.team, []); - byTeam.set('everyone else', []); - for (const p of pulls) byTeam.get(memberOf(p.data.user.login) ?? 'everyone else')!.push(p); - const nonEmpty = [...byTeam.entries()].filter(([, v]) => v.length); - const selected = team && byTeam.get(team)?.length ? team : nonEmpty[0]?.[0]; - if (!selected) return ; - - const teamPulls = byTeam.get(selected)!; - const members = teams.find(t => t.team === selected)?.members ?? [ - ...new Set(teamPulls.map(p => p.data.user.login)), - ]; - const statusCounts = new Map(); - for (const p of teamPulls) statusCounts.set(p.status, (statusCounts.get(p.status) ?? 0) + 1); - - const reviewable = crSort( - teamPulls.filter( - p => ['needs_cr', 'needs_recr'].includes(p.status) && p.data.user.login !== opts.me - ) - ); - const rest = teamPulls - .filter(p => !reviewable.includes(p)) - .sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status)); - - return ( - <> -
- {nonEmpty.map(([name, v]) => ( - - ))} -
- -
- - {members.slice(0, 12).map(m => ( - - ))} - - - {selected} -
- - {members.length} members · {teamPulls.length} open PRs - -
- - {[...statusCounts.entries()].map(([status, count]) => ( - - {count} - {STATUS_LABEL[status as keyof typeof STATUS_LABEL].toLowerCase()} - - ))} - -
- - - {rest.length > 0 && ( - - - {rest.slice(0, 20).map(p => ( - - ))} - - - )} - - ); -} From 9a7511858ebc0f18a67840f34f1ec376c6657568 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 18:18:48 -0700 Subject: [PATCH 017/381] Copy honesty and screen-reader structure across the shell The copy findings from the clarify review, applied everywhere the component passes didn't already reach: - Board hints stop misassigning work: 'merge these' told the READER to merge (the row cue says the author can); 'authors fix first' is only usually true (fleet CI breakage is anyone's re-run); 'not ready for eyes' becomes 'not ready for review'; the blocked hint teaches the real model (conflicts only land here once signed off). - 'viewing as' implied an impersonation switcher; 'signed in as'. - '1 PRs changed' grammar: counts go through a tiny n() helper. - The theme toggle names the mode it switches TO, persists the explicit choice, and follows OS scheme changes until one is made (it used to reset on every reload and ignore sunset auto-switching). - Remaining em-dash strings replaced per the house copy rule. Screen-reader structure: lane and Board column titles are real h2 headings (the page had zero heading navigation across 5-8 sections), Board's collapse exposes aria-expanded, tabs mark aria-current, the connection dot gets sr-only text (it was a 7px color-only signal), the filter input has an accessible name, header row one wraps instead of forcing page-wide horizontal scroll at split-screen widths, and the '/' hotkey guard covers textareas. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 54 ++++++++++++++++++++--------- frontend-v2/src/components/Lane.tsx | 4 +-- frontend-v2/src/format.ts | 5 +++ frontend-v2/src/views/Board.tsx | 41 ++++++++++++---------- 4 files changed, 67 insertions(+), 37 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 385388c1..13bfb669 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { ago } from './format'; +import { ago, n } from './format'; +import { readStorage, writeStorage } from './storage'; import { STATUS_ORDER, type DerivedPull } from './model/status'; import type { Team } from './types'; import { usePulldasher } from './store'; @@ -15,6 +16,7 @@ import { Board } from './views/Board'; type Lens = 'review' | 'mine' | 'people' | 'board'; +const THEME_KEY = 'pd2.theme'; const BOT_LOGINS = new Set(['ifixit-systems']); const isBot = (p: DerivedPull) => p.data.user.login.endsWith('[bot]') || BOT_LOGINS.has(p.data.user.login); @@ -39,21 +41,37 @@ export function App() { const [onlyChanged, setOnlyChanged] = useState(false); const [showHidden, setShowHidden] = useState(false); const [teams, setTeams] = useState([]); - const [dark, setDark] = useState(() => matchMedia('(prefers-color-scheme: dark)').matches); + // explicit choice persists; otherwise follow the OS, live + const [dark, setDarkState] = useState( + () => + readStorage(THEME_KEY) ?? + (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + ); + const setDark = (next: string) => { + setDarkState(next); + writeStorage(THEME_KEY, next); + }; const searchRef = useRef(null); useEffect(() => { void loadTeams().then(setTeams); }, []); useEffect(() => { - document.documentElement.classList.toggle('dark', dark); + document.documentElement.classList.toggle('dark', dark === 'dark'); }, [dark]); + useEffect(() => { + if (readStorage(THEME_KEY)) return; + const mq = matchMedia('(prefers-color-scheme: dark)'); + const follow = () => setDarkState(mq.matches ? 'dark' : 'light'); + mq.addEventListener('change', follow); + return () => mq.removeEventListener('change', follow); + }, []); // v1's `/` hotkey: jump to the filter box from anywhere useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== '/' || e.metaKey || e.ctrlKey) return; const t = e.target as HTMLElement; - if (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.isContentEditable) return; + if (['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName) || t.isContentEditable) return; e.preventDefault(); searchRef.current?.focus(); searchRef.current?.select(); @@ -132,6 +150,7 @@ export function App() { const tab = (id: Lens, label: string, count?: number) => (
@@ -226,7 +248,7 @@ export function App() { ? 'border-brand bg-brand-50 text-brand-700' : 'border-line bg-surface text-ink-3 hover:text-brand' }`} - title="Cryogenic-Storage pulls and hide-by-default repos" + title="Cryogenic-Storage PRs and hide-by-default repos" > ❄ {hiddenCount} hidden @@ -273,8 +295,8 @@ export function App() {
- {changedCount} PRs changed since your last - look + {n(changedCount, 'PR')} changed since your + last look +

+ +

{open && (
{ordered.map(p => ( From b6a8464d7bad8fc4c03085edc59044186be690fa Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 18:21:58 -0700 Subject: [PATCH 018/381] Perf: skip untouched rows; polish: one entrance, shareable views The audit traced the render hot path: every 250ms publish rebuilt all ~180 DerivedPull objects with fresh identity and nothing was memoized, so a bulk-refresh burst reconciled the full tree 4x/second. The store already caches derive() per PullData reference (previous commit); Row is now memoized against that stable identity plus its options, and the options object App hands down is itself stable (useMemo/useCallback). Net: a pullChange re-renders one row, not 180. The entrance stagger replayed on every tab click (main was keyed by lens to force it). A tool checked twenty times a day must switch tabs instantly: the settle animation is now gated by a class that drops 700ms after first mount, and the lens key is gone. Lens, person, and team selections now mirror into the URL hash (replaceState). The persona walkthroughs ranked unshareable state the top cross-persona problem: a lead couldn't send 'here's our board' in the standup thread, and F5 dumped everyone back to Review. Hash reads back on load and on hashchange, so those links work both ways. Co-Authored-By: Claude Fable 5 --- frontend-v2/README.md | 15 ++++--- frontend-v2/src/app.tsx | 72 ++++++++++++++++++++++++------ frontend-v2/src/components/Row.tsx | 21 ++++++++- frontend-v2/src/styles.css | 11 ++--- 4 files changed, 92 insertions(+), 27 deletions(-) diff --git a/frontend-v2/README.md b/frontend-v2/README.md index bf1dc853..540de7b6 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -13,10 +13,13 @@ serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol - **The re-stamp lane.** Inactive CR signatures (invalidated by a push) are already on the wire; a pull that was reviewed and fixed but lacks a fresh stamp is the cheapest review on the board, so it leads. -- **Three lenses, one filter.** For you / People / Teams over the same pool, - with a single saved Scope (repos + people, with GitHub-team presets). -- **Changed since your last look.** A last-seen marker in localStorage; rows - updated after it get a tick, and a banner offers "show only changes". +- **Four lenses, one filter.** Review / My work / People (person or team + drill-down) / Board, over the same pool, with a single saved Scope + (repos + people, with GitHub-team presets). Lens and drill-down live in + the URL hash, so views are shareable. +- **Changed since your last look.** A last-seen marker in localStorage, + stamped when you leave (pagehide), not when you arrive; rows updated after + it get a dot, and a banner offers "show only changes". ## Running it @@ -32,8 +35,8 @@ npm test # vitest: status-derivation and CI-verdict unit tests ## Teams config Copy `public/teams.example.json` to `public/teams.json` (gitignored) and list -GitHub team slugs with member logins. It powers the Teams lens and the scope -presets; without it those features quietly disappear. Long-term this belongs +GitHub team slugs with member logins. It powers the team chips on the People +lens and the scope presets; without it those features quietly disappear. Long-term this belongs in the backend (fetch org teams via the existing Octokit client and ship them with the socket handshake). diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 13bfb669..555415d4 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ago, n } from './format'; import { readStorage, writeStorage } from './storage'; import { STATUS_ORDER, type DerivedPull } from './model/status'; @@ -16,6 +16,28 @@ import { Board } from './views/Board'; type Lens = 'review' | 'mine' | 'people' | 'board'; +const LENSES: Lens[] = ['review', 'mine', 'people', 'board']; + +/** Lens and drill-down selections live in the hash: shareable, bookmarkable. */ +function readHash() { + const p = new URLSearchParams(location.hash.slice(1)); + const lens = p.get('lens') as Lens | null; + return { + lens: lens && LENSES.includes(lens) ? lens : ('review' as Lens), + person: p.get('person'), + team: p.get('team'), + }; +} + +function writeHash(lens: Lens, person: string | null, team: string | null) { + const p = new URLSearchParams(); + if (lens !== 'review') p.set('lens', lens); + if (person) p.set('person', person); + if (team) p.set('team', team); + const next = p.toString(); + history.replaceState(null, '', next ? `#${next}` : location.pathname + location.search); +} + const THEME_KEY = 'pd2.theme'; const BOT_LOGINS = new Set(['ifixit-systems']); const isBot = (p: DerivedPull) => @@ -34,9 +56,9 @@ export function App() { lastSeen, } = usePulldasher(); const [scope] = useScope(); - const [lens, setLens] = useState('review'); - const [person, setPerson] = useState(null); - const [team, setTeam] = useState(null); + const [lens, setLens] = useState(() => readHash().lens); + const [person, setPerson] = useState(() => readHash().person); + const [team, setTeam] = useState(() => readHash().team); const [query, setQuery] = useState(''); const [onlyChanged, setOnlyChanged] = useState(false); const [showHidden, setShowHidden] = useState(false); @@ -56,6 +78,25 @@ export function App() { useEffect(() => { void loadTeams().then(setTeams); }, []); + useEffect(() => { + writeHash(lens, person, team); + }, [lens, person, team]); + useEffect(() => { + const onHash = () => { + const h = readHash(); + setLens(h.lens); + setPerson(h.person); + setTeam(h.team); + }; + window.addEventListener('hashchange', onHash); + return () => window.removeEventListener('hashchange', onHash); + }, []); + // the entrance settle runs once per visit, not on every tab switch + const [entrance, setEntrance] = useState(true); + useEffect(() => { + const t = setTimeout(() => setEntrance(false), 700); + return () => clearTimeout(t); + }, []); useEffect(() => { document.documentElement.classList.toggle('dark', dark === 'dark'); }, [dark]); @@ -136,15 +177,16 @@ export function App() { for (const p of humans) statusCounts.set(p.status, (statusCounts.get(p.status) ?? 0) + 1); const isScoped = scope.repos.length || scope.authors.length || query || onlyChanged; - const rowOpts: RowOptions = { - me, - lastSeen, - onPerson: login => { - setPerson(login); - setTeam(null); - setLens('people'); - }, - }; + const onPerson = useCallback((login: string) => { + setPerson(login); + setTeam(null); + setLens('people'); + }, []); + // stable identity so memo(Row) can skip untouched rows on socket bursts + const rowOpts: RowOptions = useMemo( + () => ({ me, lastSeen, onPerson }), + [me, lastSeen, onPerson] + ); const mineCount = humans.filter(p => p.data.user.login === me).length; const tab = (id: Lens, label: string, count?: number) => ( @@ -309,7 +351,9 @@ export function App() {
)} -
+
{!initialized && !authFailed && (
diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 66380590..9b77c8db 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { memo, useState } from 'react'; import type { DerivedPull } from '../model/status'; import { isIterating } from '../model/status'; import { ago, githubUrl, shortRepo } from '../format'; @@ -115,7 +115,7 @@ function RowActions({ pull }: { pull: DerivedPull }) { ); } -export function Row({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { +function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const d = pull.data; const fresh = Date.parse(d.updated_at) / 1000 > opts.lastSeen; const pips = opts.pips ?? 'both'; @@ -178,3 +178,20 @@ export function Row({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) {
); } + +/** + * Rows re-render only when their pull is re-derived (the store caches + * derive() per PullData reference) or an option actually changes — a burst + * of pullChange events must not reconcile 180 untouched rows. + */ +export const Row = memo( + RowImpl, + (a, b) => + a.pull === b.pull && + a.opts.badge === b.opts.badge && + a.opts.pips === b.opts.pips && + a.opts.aging === b.opts.aging && + a.opts.me === b.opts.me && + a.opts.lastSeen === b.opts.lastSeen && + a.opts.onPerson === b.opts.onPerson +); diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 9e65d4e5..108809cd 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -183,13 +183,14 @@ a:hover { translate: 0 8px; } } -main > * { +/* first paint only: tab switches into a task must be instant */ +.settle-once > * { animation: settle 300ms var(--ease-quint) backwards; } -main > *:nth-child(2) { animation-delay: 40ms; } -main > *:nth-child(3) { animation-delay: 80ms; } -main > *:nth-child(4) { animation-delay: 120ms; } -main > *:nth-child(n + 5) { animation-delay: 160ms; } +.settle-once > *:nth-child(2) { animation-delay: 40ms; } +.settle-once > *:nth-child(3) { animation-delay: 80ms; } +.settle-once > *:nth-child(4) { animation-delay: 120ms; } +.settle-once > *:nth-child(n + 5) { animation-delay: 160ms; } details[open] > .fold-body { animation: settle 220ms var(--ease-quint); From 532e5e1b5eea46eb1a5dd2a411d84d131f20f3ce Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 18:24:58 -0700 Subject: [PATCH 019/381] Live-check fixlets: singular draft fold, People defaults to a teammate Two things the in-browser pass over the dummy board surfaced: the drafts fold read '1 drafts', and the People lens defaulted to the signed-in user - whose PRs are exactly the ones they can't review, so the top lane opened empty every time. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/views/People.tsx | 6 ++++-- frontend-v2/src/views/Review.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx index a4d65a93..973a44fe 100644 --- a/frontend-v2/src/views/People.tsx +++ b/frontend-v2/src/views/People.tsx @@ -44,9 +44,11 @@ export function People({ ); // an explicit pick always wins, even with zero open PRs — silently - // showing someone else's board mid-conversation is worse than an empty one + // showing someone else's board mid-conversation is worse than an empty one. + // The default skips yourself: you can't review your own PRs. + const defaultPerson = logins.find(l => l !== opts.me) ?? logins[0]; const selectedTeam = team && teams.some(t => t.team === team) ? team : null; - const selectedPerson = selectedTeam ? null : (person ?? logins[0]); + const selectedPerson = selectedTeam ? null : (person ?? defaultPerson); if (!selectedPerson && !selectedTeam) return null; const members = selectedTeam diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 1ab677eb..a86aca47 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -146,7 +146,7 @@ export function Review({ p.status === 'draft').length} - label="drafts" + label={others.filter(p => p.status === 'draft').length === 1 ? 'draft' : 'drafts'} hint="not reviewable yet" > {foldRows(others.filter(p => p.status === 'draft'))} From 84041a73ebfb92022134aa73fedcefade4c611cd Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 19:21:46 -0700 Subject: [PATCH 020/381] Add a Classic lens: the v1 board, nearly unchanged, inside v2 Some people's muscle memory lives in the old board, and the Board lens deliberately changed its semantics (one column per pull). Classic gives them v1 as-was: the same six overlapping columns in the same order (CI Blocked / Deploy Blocked / Ready / Dev Block / CR / QA), with the predicates and sorts ported line-for-line from frontend/src/pulldasher (defaultCompare's mine-first / my-outdated-stamps / untouched / youngest ordering, QACompare, DeployCompare). Where v1 read raw wire fields (dev_block[0] with no active check), Classic does too - near-unchanged beats more-correct in this lens. Empty columns stay visible, a pull can sit in several columns at once, bots ride inline, and cryo pulls hide behind the same toggle v1 hid them behind. Verified against the dummy fixture: every column count matches the v1 predicates computed independently (CI 1 / Deploy 3 / Ready 2 / Dev 19 / CR 22 / QA 19 after the two cryo pulls hide), and the CR column leads with the signed-in user's own pulls, as v1 always did. Co-Authored-By: Claude Fable 5 --- frontend-v2/README.md | 13 ++- frontend-v2/src/app.tsx | 7 +- frontend-v2/src/views/Classic.tsx | 129 ++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 frontend-v2/src/views/Classic.tsx diff --git a/frontend-v2/README.md b/frontend-v2/README.md index 540de7b6..55682ad7 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -13,10 +13,15 @@ serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol - **The re-stamp lane.** Inactive CR signatures (invalidated by a push) are already on the wire; a pull that was reviewed and fixed but lacks a fresh stamp is the cheapest review on the board, so it leads. -- **Four lenses, one filter.** Review / My work / People (person or team - drill-down) / Board, over the same pool, with a single saved Scope - (repos + people, with GitHub-team presets). Lens and drill-down live in - the URL hash, so views are shareable. +- **Five lenses, one filter.** Review / My work / People (person or team + drill-down) / Board / Classic, over the same pool, with a single saved + Scope (repos + people, with GitHub-team presets). Lens and drill-down live + in the URL hash, so views are shareable. +- **Classic is v1, faithfully.** The same six overlapping columns + (CI Blocked / Deploy Blocked / Ready / Dev Block / CR / QA), predicates + and sorts ported line-for-line from v1, for anyone whose muscle memory + lives there. Board is the v2 take on the same idea (one column per pull, + mutually exclusive statuses). - **Changed since your last look.** A last-seen marker in localStorage, stamped when you leave (pagehide), not when you arrive; rows updated after it get a dot, and a banner offers "show only changes". diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 555415d4..fa32f102 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -13,10 +13,11 @@ import { Review } from './views/Review'; import { MyWork } from './views/MyWork'; import { People } from './views/People'; import { Board } from './views/Board'; +import { Classic } from './views/Classic'; -type Lens = 'review' | 'mine' | 'people' | 'board'; +type Lens = 'review' | 'mine' | 'people' | 'board' | 'classic'; -const LENSES: Lens[] = ['review', 'mine', 'people', 'board']; +const LENSES: Lens[] = ['review', 'mine', 'people', 'board', 'classic']; /** Lens and drill-down selections live in the hash: shareable, bookmarkable. */ function readHash() { @@ -260,6 +261,7 @@ export function App() { {tab('mine', 'My work', mineCount)} {tab('people', 'People')} {tab('board', 'Board')} + {tab('classic', 'Classic')} )} {initialized && lens === 'board' && } + {initialized && lens === 'classic' && }
); diff --git a/frontend-v2/src/views/Classic.tsx b/frontend-v2/src/views/Classic.tsx new file mode 100644 index 00000000..5e1f6bb1 --- /dev/null +++ b/frontend-v2/src/views/Classic.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react'; +import type { DerivedPull } from '../model/status'; +import { EmptyState } from '../components/bits'; +import { Row, type RowOptions } from '../components/Row'; + +/** + * The v1 board, faithfully: the same six overlapping columns, the same + * predicates, the same sorts, straight from frontend/src/pulldasher/index.tsx + * and pulldasher/sort.ts. A pull can appear in several columns at once and + * empty columns stay visible — that spatial constancy is the muscle memory + * this lens exists to preserve. New concepts (lanes, one-status-per-pull, + * folds) stay out of here on purpose. + */ + +// v1 predicate ports. Where v1 read raw wire fields (dev_block[0] with no +// active check), so does this — near-unchanged beats more-correct here. +const devBlock = (p: DerivedPull) => !!p.data.status.dev_block[0]; +const deployBlock = (p: DerivedPull) => !!p.data.status.deploy_block[0]; +const isDraft = (p: DerivedPull) => p.data.draft; +// v1 hasPassedCI(): every required status successful; no statuses and no +// required list counts as passed. ciVerdict encodes exactly that split. +const passedCI = (p: DerivedPull) => p.ci === 'success' || p.ci === 'none'; +const crDone = (p: DerivedPull) => p.crHave >= p.data.status.cr_req; +const qaDone = (p: DerivedPull) => p.qaHave >= p.data.status.qa_req; +const metDeployReqs = (p: DerivedPull) => crDone(p) && qaDone(p) && passedCI(p); + +const hasOutdatedSig = (p: DerivedPull, me: string) => + [...p.data.status.allCR, ...p.data.status.allQA].some( + s => !s.data.active && s.data.user.login === me + ); +const hasCurrentSig = (p: DerivedPull, me: string) => p.crBy.includes(me) || p.qaBy.includes(me); + +const cmp = (a: boolean, b: boolean) => Number(b) - Number(a); + +/** v1 defaultCompare: mine, then my owed re-stamps, then untouched, then youngest. */ +const defaultCompare = (me: string) => (a: DerivedPull, b: DerivedPull) => + cmp(a.data.user.login === me, b.data.user.login === me) || + cmp(hasOutdatedSig(a, me), hasOutdatedSig(b, me)) || + cmp(!hasCurrentSig(a, me), !hasCurrentSig(b, me)) || + b.data.created_at.localeCompare(a.data.created_at); + +const qaCompare = (me: string) => (a: DerivedPull, b: DerivedPull) => + cmp(a.qaingBy === me, b.qaingBy === me) || + cmp(!a.externalBlock, !b.externalBlock) || + cmp(!a.conflict, !b.conflict) || + cmp(!a.qaingBy, !b.qaingBy) || + cmp(crDone(a), crDone(b)) || + b.data.created_at.localeCompare(a.data.created_at); + +const deployCompare = (a: DerivedPull, b: DerivedPull) => cmp(!a.conflict, !b.conflict); + +function Column({ title, pulls, opts }: { title: string; pulls: DerivedPull[]; opts: RowOptions }) { + const [open, setOpen] = useState(true); + return ( +
+

+ +

+ {open && ( +
+ {pulls.map(p => ( + + ))} + {!pulls.length &&
none
} +
+ )} +
+ ); +} + +export function Classic({ pulls, opts }: { pulls: DerivedPull[]; opts: RowOptions }) { + const me = opts.me; + if (!pulls.length) { + return ; + } + const base = [...pulls].sort(defaultCompare(me)); + + const ciBlocked = base.filter(p => !devBlock(p) && !passedCI(p) && !isDraft(p)); + const deployBlocked = base + .filter( + p => metDeployReqs(p) && !devBlock(p) && (deployBlock(p) || p.conflict || p.dependent) + ) + .sort(deployCompare); + const ready = base.filter( + p => + metDeployReqs(p) && + !isDraft(p) && + !devBlock(p) && + !deployBlock(p) && + !p.conflict && + !p.dependent + ); + const devBlocked = base.filter(p => devBlock(p) || isDraft(p)); + const needsCr = base.filter(p => !crDone(p) && !devBlock(p) && !isDraft(p)); + const needsQa = base + .filter(p => !qaDone(p) && !devBlock(p) && !isDraft(p) && !p.conflict && passedCI(p)) + .sort(qaCompare(me)); + + const columns: [string, DerivedPull[]][] = [ + ['CI Blocked', ciBlocked], + ['Deploy Blocked', deployBlocked], + ['Ready', ready], + ['Dev Block', devBlocked], + ['CR', needsCr], + ['QA', needsQa], + ]; + + return ( +
+ {columns.map(([title, list]) => ( + + ))} +
+ ); +} From 387760bdba1e77f9d494c29305445966f270a8dd Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 20:09:36 -0700 Subject: [PATCH 021/381] Layout audit: compact rows for column views, honest lane counts First real screenshot pass over every lens (light + dark, 1440 and 900px) with a programmatic overflow scan. What it caught: - Board and Classic crushed titles to two letters: the full row anatomy (cue sentence, warning flags, hover actions, weight chip) claimed its fixed widths inside 340-460px columns and the flexible title paid for all of it, while the right edge clipped pips and ages. Rows now have a compact mode for column views: the title owns the space; avatar, repo#number, CR/QA pips, and age remain; a lone QAing dot survives because the QA column needs the claim marker. Hover actions go too - they reserve layout space even at opacity 0. - Both My work lane headers read 0 while five rows rendered: Lane counted its pulls prop, and My work feeds rows through the children slot. Lanes take an explicit count now. - My work said everything twice: the row's cue line repeated the right-hand waiting column word for word, wrapping to two cramped lines. The cue suppresses when the view carries the explanation, and the waiting column is a fixed, truncating cell with the full text on hover. - The unknown-size '?' weight chip repeated on every row as a little green error. When the wire has no diff sizes the chip just doesn't render - absence is quieter than a shrug. - Board's floating text column headers and Classic's attached bars were two treatments for the same concept; both now use the attached header bar (Board keeps its status dot and hint). - '0 bot PRs' rode in the header permanently; it hides at zero. Overflow scan is clean at 1440 and 900 in both themes; screenshots verified per lens. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 6 +++++- frontend-v2/src/components/Lane.tsx | 5 ++++- frontend-v2/src/components/Row.tsx | 29 +++++++++++++++++++++++------ frontend-v2/src/components/bits.tsx | 8 ++------ frontend-v2/src/views/Board.tsx | 16 +++++++++------- frontend-v2/src/views/Classic.tsx | 6 ++++-- frontend-v2/src/views/MyWork.tsx | 29 +++++++++++++++++++++-------- 7 files changed, 68 insertions(+), 31 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index fa32f102..362b85e0 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -298,7 +298,11 @@ export function App() { )} - {bots.length} bot PRs + {bots.length > 0 && ( + + {n(bots.length, 'bot PR')} + + )}
diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index 6dd70626..beca4572 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -12,6 +12,7 @@ export function Lane({ title, sub, pulls, + count, cap = 10, opts, children, @@ -19,6 +20,8 @@ export function Lane({ title: string; sub: string; pulls: DerivedPull[]; + /** header count when rows come in via children instead of pulls */ + count?: number; cap?: number; opts: RowOptions; /** extra rows rendered inside the container, before the more-line */ @@ -34,7 +37,7 @@ export function Lane({

{title}

{sub} - {pulls.length} + {count ?? pulls.length}
{children} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 9b77c8db..3a8a219b 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -11,6 +11,14 @@ export interface RowOptions { pips?: 'cr' | 'qa' | 'both' | 'none'; /** show the open-Nd flag on starved pulls */ aging?: boolean; + /** + * Column mode (Board/Classic): the title owns the space. Drops the cue + * line, warning flags, weight chip, and hover actions — a full row's + * anatomy crushes titles to two letters inside a 340px column. + */ + compact?: boolean; + /** suppress the cue line when the view says the same thing beside the row */ + cue?: boolean; me: string; lastSeen: number; onPerson?: (login: string) => void; @@ -117,11 +125,13 @@ function RowActions({ pull }: { pull: DerivedPull }) { function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const d = pull.data; + const compact = opts.compact === true; const fresh = Date.parse(d.updated_at) / 1000 > opts.lastSeen; const pips = opts.pips ?? 'both'; - const showWeight = ['needs_cr', 'needs_recr'].includes(pull.status); + const showWeight = + !compact && pull.sizeKnown && ['needs_cr', 'needs_recr'].includes(pull.status); const staleCr = pull.recrBy.length > 0; - const line = cue(pull, opts.me); + const line = compact || opts.cue === false ? null : cue(pull, opts.me); return (
- - {isIterating(d) && ( + {!compact && } + {!compact && isIterating(d) && ( iterating @@ -154,14 +164,19 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { open {pull.ageDays}d )} - + {!compact && } + {compact && pull.qaingBy && pull.status === 'needs_qa' && ( + + ◉ + + )} {line && ( {line} )} - {showWeight && } + {showWeight && } {(pips === 'cr' || pips === 'both') && ( )} @@ -191,6 +206,8 @@ export const Row = memo( a.opts.badge === b.opts.badge && a.opts.pips === b.opts.pips && a.opts.aging === b.opts.aging && + a.opts.compact === b.opts.compact && + a.opts.cue === b.opts.cue && a.opts.me === b.opts.me && a.opts.lastSeen === b.opts.lastSeen && a.opts.onPerson === b.opts.onPerson diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 83fbf5c1..e28fc737 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -77,15 +77,11 @@ export function Avatar({ ); } -export function WeightChip({ weight, known = true }: { weight: Weight; known?: boolean }) { - return known ? ( +export function WeightChip({ weight }: { weight: Weight }) { + return ( {weight} - ) : ( - - ? - ); } diff --git a/frontend-v2/src/views/Board.tsx b/frontend-v2/src/views/Board.tsx index da869f32..f519ecae 100644 --- a/frontend-v2/src/views/Board.tsx +++ b/frontend-v2/src/views/Board.tsx @@ -39,31 +39,33 @@ function Column({ const ordered = ['needs_cr', 'needs_recr'].includes(status) ? crSort(pulls) : pulls; return (
-

+

{open && ( -
+
{ordered.map(p => ( ))}
diff --git a/frontend-v2/src/views/Classic.tsx b/frontend-v2/src/views/Classic.tsx index 5e1f6bb1..1ce889b6 100644 --- a/frontend-v2/src/views/Classic.tsx +++ b/frontend-v2/src/views/Classic.tsx @@ -58,7 +58,9 @@ function Column({ title, pulls, opts }: { title: string; pulls: DerivedPull[]; o type="button" aria-expanded={open} onClick={() => setOpen(o => !o)} - className="flex w-full items-center gap-2 rounded-t-2xl border border-line bg-muted px-4 py-2.5 text-left text-sm font-semibold" + className={`flex w-full items-center gap-2 border border-line bg-muted px-4 py-2.5 text-left text-sm font-semibold ${ + open ? 'rounded-t-2xl' : 'rounded-2xl' + }`} title={open ? 'collapse column' : 'expand column'} > {title} @@ -72,7 +74,7 @@ function Column({ title, pulls, opts }: { title: string; pulls: DerivedPull[]; o ))} {!pulls.length &&
none
} diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index 8330987c..1669be0f 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -65,14 +65,23 @@ export function MyWork({ return ( <> - + {move.map(p => ( -
- +
+ {yourMove(p)} - +
))} @@ -86,15 +95,19 @@ export function MyWork({ title="Waiting on others" sub="who to nudge, and how long it’s been" pulls={[]} + count={waiting.length} opts={opts} > {waiting.map(p => ( -
+
- + - - {waitingOn(p)} + + {waitingOn(p)}
))} From c52d7d60f32a15664cff2d78d5b43f8fb07581d3 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 20:13:58 -0700 Subject: [PATCH 022/381] Never truncate a PR title: wrap to as many lines as it takes The title is how a reviewer decides whether to open a PR - an ellipsis at 30 characters withholds exactly the information the board exists to surface, and the compact column views were the worst offenders. Titles now wrap instead of truncating, everywhere a row renders (open rows and closed rows), with break-words so a branch-name-style title can't force horizontal overflow. Rows grow vertically; the repo#number stays on one piece so the identifier never splits across lines. Verified in the tightest layout (Classic's ~460px columns, and at a 900px viewport): zero truncated titles, zero overflowing elements, pips and ages stay centered against multi-line rows. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/ClosedRow.tsx | 2 +- frontend-v2/src/components/Row.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index d069f79a..f5773adc 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -14,7 +14,7 @@ export function ClosedRow({ pull }: { pull: PullData }) { {merged ? 'Merged' : 'Closed'} - + {shortRepo(pull.repo)}#{pull.number} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 3a8a219b..0fc5d099 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -140,8 +140,9 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { {fresh && } {opts.badge === false ? null : } - - + {/* the title is how a reviewer decides: never truncate it, wrap instead */} + + {shortRepo(d.repo)}#{d.number}
Date: Thu, 16 Jul 2026 20:23:22 -0700 Subject: [PATCH 023/381] Move repo#number into the metadata cluster beside CR/QA The title line now carries only the title: repo#number is metadata, not the reviewer's decision input, so it lives with its peers (weight, pips, age) in the right-hand cluster where the eye already goes for facts. Rows scan as avatar + title on the left, aligned data on the right, in every lens. Closed rows do the same (repo#number rides with the merged/closed age). Verified: no truncated titles, zero overflowing elements in Review and Classic, and a rendered long title matches the fixture byte-for-byte. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/ClosedRow.tsx | 7 +++---- frontend-v2/src/components/Row.tsx | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index f5773adc..48858b4a 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -15,9 +15,6 @@ export function ClosedRow({ pull }: { pull: PullData }) { - - {shortRepo(pull.repo)}#{pull.number} - - {ago(closedAt)} ago + + {shortRepo(pull.repo)}#{pull.number} · {ago(closedAt)} ago +
); } diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 0fc5d099..ae11ec08 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -142,9 +142,6 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { {/* the title is how a reviewer decides: never truncate it, wrap instead */} - - {shortRepo(d.repo)}#{d.number} - )} + + {shortRepo(d.repo)}#{d.number} + {showWeight && } {(pips === 'cr' || pips === 'both') && ( From a460b602187655a22be84fac69323a4739843079 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 20:34:54 -0700 Subject: [PATCH 024/381] Redesign the column card: two zones instead of one fought-over line In Board/Classic columns the single-line row anatomy made the nowrap metadata cluster (repo#number, pips, age) eat ~170px of a 300px column, crushing the title into a ~120px strip that wrapped 3-5 lines against a dead column of whitespace. Cards ran 80-110px tall for one PR. The card now has two zones: identity (avatar + title at full text width, leading-snug, wrapping twice at most in practice) over one fact line (repo#number leading, CR/QA pips, QAing dot, age pushed to the right edge with ml-auto so ages scan as a vertical column down the board). Typical card drops to ~60-75px with no ragged whitespace. Also defines the missing .dot-fresh rule: Row and the legend referenced the class but no CSS existed, so the changed-since-last-look marker rendered invisible. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Row.tsx | 76 +++++++++++++++++++++++++----- frontend-v2/src/styles.css | 9 ++++ 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index ae11ec08..61f575e1 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -123,15 +123,74 @@ function RowActions({ pull }: { pull: DerivedPull }) { ); } +/** + * Column card (Board/Classic): two zones instead of one fought-over line. + * Zone 1 is identity — avatar plus the title at full text width, so a long + * title wraps twice instead of five times against a dead metadata column. + * Zone 2 is one fact line — repo#number, pips, age pushed to the right edge + * so ages scan as a vertical column down the board. + */ +function CompactCard({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { + const d = pull.data; + const fresh = Date.parse(d.updated_at) / 1000 > opts.lastSeen; + const pips = opts.pips ?? 'both'; + const staleCr = pull.recrBy.length > 0; + return ( +
+ {fresh && } + + + + + + + {d.title} + + + + + {shortRepo(d.repo)}#{d.number} + + {(pips === 'cr' || pips === 'both') && ( + + )} + {(pips === 'qa' || pips === 'both') && ( + + )} + {pull.qaingBy && pull.status === 'needs_qa' && ( + + ◉ + + )} + + {pull.ageDays}d + + + +
+ ); +} + function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const d = pull.data; const compact = opts.compact === true; const fresh = Date.parse(d.updated_at) / 1000 > opts.lastSeen; const pips = opts.pips ?? 'both'; - const showWeight = - !compact && pull.sizeKnown && ['needs_cr', 'needs_recr'].includes(pull.status); + if (compact) return ; + + const showWeight = pull.sizeKnown && ['needs_cr', 'needs_recr'].includes(pull.status); const staleCr = pull.recrBy.length > 0; - const line = compact || opts.cue === false ? null : cue(pull, opts.me); + const line = opts.cue === false ? null : cue(pull, opts.me); return (
- {!compact && } - {!compact && isIterating(d) && ( + + {isIterating(d) && ( iterating @@ -162,12 +221,7 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { open {pull.ageDays}d )} - {!compact && } - {compact && pull.qaingBy && pull.status === 'needs_qa' && ( - - ◉ - - )} + {line && ( {line} diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 108809cd..c95b53de 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -231,6 +231,15 @@ details[open] > .fold-body { animation: fresh-flash 1.4s var(--ease-quart) 300ms backwards; } +/* the "changed since your last look" marker at the row's entry point */ +.dot-fresh { + flex: none; + width: 7px; + height: 7px; + border-radius: 9999px; + background: var(--brand); +} + /* press feedback: house scale-97 */ .pressable { transition: From 338abfc04911d02b6d40cba973c4889aedf6dff4 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 21:09:58 -0700 Subject: [PATCH 025/381] Fact line legibility: split repo from number, glanceable CR/QA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo#number token read as one dense blob, and the sign-off pips required counting 6px dots to know where a pull stood. RepoRef splits the reference typographically: the repo whispers in ink-3 (it's ambient context, usually ifixit) and the number steps forward in ink-2 medium — the number is what you say out loud. Used in the column card, the wide row, and closed rows. Pips now answer at a glance instead of asking you to count: a green check when sign-offs are satisfied, a tabular fraction (1/2) while outstanding, the amber slash when a push invalidated the stamp, and nothing at all when nothing is required or given — which also kills the confusing bare "QA" labels on pulls with no QA requirement. Legend updated to the new vocabulary; dead pip and heat-bar CSS removed. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/ClosedRow.tsx | 9 ++--- frontend-v2/src/components/Legend.tsx | 10 +++--- frontend-v2/src/components/Row.tsx | 12 +++---- frontend-v2/src/components/bits.tsx | 46 +++++++++++++++++++----- frontend-v2/src/styles.css | 19 ---------- 5 files changed, 52 insertions(+), 44 deletions(-) diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index 48858b4a..7de6df07 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -1,6 +1,6 @@ import type { PullData } from '../types'; -import { ago, githubUrl, shortRepo } from '../format'; -import { Avatar } from './bits'; +import { ago, githubUrl } from '../format'; +import { Avatar, RepoRef } from './bits'; export function ClosedRow({ pull }: { pull: PullData }) { const merged = !!pull.merged_at; @@ -24,8 +24,9 @@ export function ClosedRow({ pull }: { pull: PullData }) { {pull.title} - - {shortRepo(pull.repo)}#{pull.number} · {ago(closedAt)} ago + + + {ago(closedAt)} ago
); diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index fb686741..75528f30 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -61,13 +61,13 @@ export function Legend() { 'a CR or QA sign-off, left as a comment on the PR' )} {item( - <> - - , - 'sign-offs: empty = still needed, filled = given' + + CR 1/2 + , + 'sign-offs given / required; a check means done' )} {item( - , + , 'a push invalidated the stamp: it needs re-CR' )} {item( diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 61f575e1..b5390121 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -1,9 +1,9 @@ import { memo, useState } from 'react'; import type { DerivedPull } from '../model/status'; import { isIterating } from '../model/status'; -import { ago, githubUrl, shortRepo } from '../format'; +import { ago, githubUrl } from '../format'; import { refreshPull } from '../store'; -import { Avatar, Pips, StatusBadge, WeightChip } from './bits'; +import { Avatar, Pips, RepoRef, StatusBadge, WeightChip } from './bits'; export interface RowOptions { /** hide the status badge when the lane already says it */ @@ -155,9 +155,7 @@ function CompactCard({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { - - {shortRepo(d.repo)}#{d.number} - + {(pips === 'cr' || pips === 'both') && ( )} @@ -228,9 +226,7 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { )} - - {shortRepo(d.repo)}#{d.number} - + {showWeight && } {(pips === 'cr' || pips === 'both') && ( diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index e28fc737..0066a1a4 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -1,5 +1,5 @@ import type { Status, Weight } from '../model/status'; -import { loginHue } from '../format'; +import { loginHue, shortRepo } from '../format'; export const STATUS_LABEL: Record = { ready: 'Ready to merge', @@ -85,6 +85,11 @@ export function WeightChip({ weight }: { weight: Weight }) { ); } +/** + * Sign-off state readable at a glance, no dot-counting: a green check when + * satisfied, a fraction while outstanding, an amber slash when a push + * invalidated the stamp. Nothing required and nothing given renders nothing. + */ export function Pips({ label, have, @@ -96,27 +101,52 @@ export function Pips({ req: number; stale?: boolean; }) { - const total = Math.max(req, have); + if (!req && !have) return null; const staleNote = stale && have === 0; + const met = have >= req; return ( {label} {staleNote ? ( - + + ⊘ + + ) : met ? ( + + ✓ + ) : ( - Array.from({ length: total }, (_, i) => ( - - )) + + {have}/{req} + )} ); } +/** + * The number is what you say out loud; the repo is ambient context. Split + * them typographically so the identifier steps forward and the repo whispers. + */ +export function RepoRef({ repo, number }: { repo: string; number: number }) { + return ( + + {shortRepo(repo)}{' '} + #{number} + + ); +} + export function EmptyState({ title, sub }: { title: string; sub: string }) { return (
diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index c95b53de..e13ffb13 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -71,7 +71,6 @@ --warn: #fbbf24; --bad: #f87171; --violet: #c084fc; - --heat0: #364153; } @theme inline { @@ -137,24 +136,6 @@ a:hover { flex: none; } - /* signature pips */ - .pip { width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid var(--ring); } - .pip-full { background: var(--ok); border-color: var(--ok); } - .pip-stale { border-color: var(--warn); position: relative; } - .pip-stale::after { - content: ""; - position: absolute; - left: -2px; - right: -2px; - top: 3px; - border-top: 1.5px solid var(--warn); - transform: rotate(-30deg); - } - - /* age heat bars */ - .heat-bar { width: 4px; height: 10px; border-radius: 2px; background: var(--heat0); } - .heat-warm { background: var(--heat1); } - .heat-hot { background: var(--heat2); } } /* hover-reveal row actions (copy branch, refresh) */ From 9bb6f005df5b99a2550abfbc9b3101ab81f34801 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 21:11:57 -0700 Subject: [PATCH 026/381] Right-align CR/QA with the age in the column card's fact line The fact line read left-to-right as one run of tokens. Grouping the state (CR/QA, QAing, age) on the right edge leaves the reference (repo #number) alone on the left: identity left, state right, and the sign-off column lines up vertically down the board like the ages do. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Row.tsx | 32 ++++++++++++++++-------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index b5390121..da0a77c7 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -156,22 +156,24 @@ function CompactCard({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { - {(pips === 'cr' || pips === 'both') && ( - - )} - {(pips === 'qa' || pips === 'both') && ( - - )} - {pull.qaingBy && pull.status === 'needs_qa' && ( - - ◉ + + {(pips === 'cr' || pips === 'both') && ( + + )} + {(pips === 'qa' || pips === 'both') && ( + + )} + {pull.qaingBy && pull.status === 'needs_qa' && ( + + ◉ + + )} + + {pull.ageDays}d - )} - - {pull.ageDays}d From 77decc69ec047870e39af04e8d4af9c7c278848b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 21:18:41 -0700 Subject: [PATCH 027/381] Make the stamp ledger deterministic: fixed slots for CR, QA, and age The sign-off marks varied in width (a check is narrow, 1/2 is wide, a not-required pip vanished entirely), so the stamps landed at a different x on every card and couldn't be read as a column. Each pip is now a fixed-slot ledger entry: an 18px label and a 26px value, with a muted dash holding the slot when nothing is required instead of collapsing. The age gets a fixed right-aligned width too, and the QAing dot sits left of the right-anchored cluster where it can't shift the ledger. Measured across all six Classic columns: every CR label lands at the same x within its card, every QA likewise. Marks also step up from 11px to 12px for glanceability. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Legend.tsx | 2 +- frontend-v2/src/components/Row.tsx | 14 +++++----- frontend-v2/src/components/bits.tsx | 38 +++++++++++++++++---------- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 75528f30..84126506 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -64,7 +64,7 @@ export function Legend() { CR 1/2 , - 'sign-offs given / required; a check means done' + 'sign-offs given / required; ✓ done, – not required' )} {item( , diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index da0a77c7..58b30cf1 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -157,19 +157,19 @@ function CompactCard({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { + {pull.qaingBy && pull.status === 'needs_qa' && ( + + ◉ + + )} {(pips === 'cr' || pips === 'both') && ( )} {(pips === 'qa' || pips === 'both') && ( )} - {pull.qaingBy && pull.status === 'needs_qa' && ( - - ◉ - - )} {pull.ageDays}d @@ -237,7 +237,7 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { )} {pull.ageDays}d diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 0066a1a4..e127963f 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -86,9 +86,11 @@ export function WeightChip({ weight }: { weight: Weight }) { } /** - * Sign-off state readable at a glance, no dot-counting: a green check when - * satisfied, a fraction while outstanding, an amber slash when a push - * invalidated the stamp. Nothing required and nothing given renders nothing. + * Sign-off state as a fixed-slot ledger: label and value each hold a constant + * width so CR, QA, and age land at the same x on every row and read as + * vertical columns down a board. A green check when satisfied, a fraction + * while outstanding, an amber slash when a push invalidated the stamp, and a + * muted dash when nothing is required — the slot never collapses. */ export function Pips({ label, @@ -101,32 +103,40 @@ export function Pips({ req: number; stale?: boolean; }) { - if (!req && !have) return null; + const none = !req && !have; const staleNote = stale && have === 0; - const met = have >= req; + const met = !none && have >= req; + const aria = none + ? `${label} not required` + : `${label} ${have} of ${req}${staleNote ? ', earlier stamp invalidated by a push' : met ? ', done' : ''}`; return ( - - + + {label} - {staleNote ? ( + {none ? ( + + – + + ) : staleNote ? ( ) : met ? ( - + ) : ( - + {have}/{req} )} From 3e78d8b1084fe310108d0e972173771238e3a338 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 21:26:31 -0700 Subject: [PATCH 028/381] Float the fresh dot in the row's padding gutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changed-since-last-look dot rendered inline before the avatar, so a fresh row's entire content shifted right by the dot's width and broke the card-to-card alignment the fixed-slot ledger just earned. The dot is now absolutely positioned in the row's left padding gutter (centered on the first title line in column cards, vertically centered in wide rows), so a row looks identical whether or not it changed — the dot rides alongside instead of participating in layout. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Row.tsx | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 58b30cf1..dc5752d0 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -137,9 +137,15 @@ function CompactCard({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const staleCr = pull.recrBy.length > 0; return (
- {fresh && } + {/* lives in the padding gutter: a changed card must not indent its content */} + {fresh && ( + + )} @@ -194,9 +200,15 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { return (
- {fresh && } + {/* lives in the padding gutter: a changed row must not indent its content */} + {fresh && ( + + )} {opts.badge === false ? null : } {/* the title is how a reviewer decides: never truncate it, wrap instead */} From 961d95b50f7517c00a810ad7cc78c4ff7508d627 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 21:33:31 -0700 Subject: [PATCH 029/381] Extend the fixed-slot ledger to every lens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of all five lenses against the new card pattern found two spots still doing their own thing: - Review's lanes trimmed pips per lane (CR-only in the review queue, QA-only in Needs QA, none on the ready fold), so the CR mark in one lane sat where QA sat in another. Now that slots are fixed-width and a not-required pip is an honest muted dash, the trimming cost page-global alignment without reducing noise — every row app-wide now renders the same [CR][QA][age] ledger, and the pips RowOption is gone. - Closed rows had a variable-width "2030d ago" vs "2h ago" timestamp that pushed the repo reference around; it gets a fixed right-aligned slot like the age column. Measured after: one x position per mark per lens — Board/Classic cards 225/283, Review/People lanes 1040/1098, My work 760/818 — folds and closed rows included. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/ClosedRow.tsx | 2 +- frontend-v2/src/components/Row.tsx | 20 ++++---------------- frontend-v2/src/views/Review.tsx | 12 ++++++------ 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index 7de6df07..b714e74c 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -26,7 +26,7 @@ export function ClosedRow({ pull }: { pull: PullData }) { - {ago(closedAt)} ago + {ago(closedAt)} ago
); diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index dc5752d0..6620ec4a 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -8,7 +8,6 @@ import { Avatar, Pips, RepoRef, StatusBadge, WeightChip } from './bits'; export interface RowOptions { /** hide the status badge when the lane already says it */ badge?: boolean; - pips?: 'cr' | 'qa' | 'both' | 'none'; /** show the open-Nd flag on starved pulls */ aging?: boolean; /** @@ -133,7 +132,6 @@ function RowActions({ pull }: { pull: DerivedPull }) { function CompactCard({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const d = pull.data; const fresh = Date.parse(d.updated_at) / 1000 > opts.lastSeen; - const pips = opts.pips ?? 'both'; const staleCr = pull.recrBy.length > 0; return (
)} - {(pips === 'cr' || pips === 'both') && ( - - )} - {(pips === 'qa' || pips === 'both') && ( - - )} + + opts.lastSeen; - const pips = opts.pips ?? 'both'; if (compact) return ; const showWeight = pull.sizeKnown && ['needs_cr', 'needs_recr'].includes(pull.status); @@ -242,12 +235,8 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { {showWeight && } - {(pips === 'cr' || pips === 'both') && ( - - )} - {(pips === 'qa' || pips === 'both') && ( - - )} + + a.pull === b.pull && a.opts.badge === b.opts.badge && - a.opts.pips === b.opts.pips && a.opts.aging === b.opts.aging && a.opts.compact === b.opts.compact && a.opts.cue === b.opts.cue && diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index a86aca47..f9d865e2 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -79,28 +79,28 @@ export function Review({ sub="re-stamps you owe, minutes each" pulls={actNow} cap={8} - opts={{ ...opts, pips: 'cr' }} + opts={opts} /> - {foldRows(ready, { pips: 'none' })} + {foldRows(ready)} - {foldRows(stamped, { pips: 'cr', badge: false })} + {foldRows(stamped, { badge: false })} Date: Thu, 16 Jul 2026 23:18:47 -0700 Subject: [PATCH 030/381] Honor v1 bookmark URLs: legacy query params open Classic configured v1 stored every setting in the query string and people have years of bookmarks encoding them: repo/author filters, cryo/drafts/external toggles, the personal view, the Recently Closed panel, and per-column collapse flags. Those URLs must keep working when v2 answers them. src/legacy.ts owns the translation: detect any known v1 param, parse with v1's defaults (other people's drafts hidden unless drafts=1, external shown unless external_block=0, SHOWALL repo sentinel), and build the same filter pipeline v1's navbar applied, including isMineViaAffiliation for personal=1. The app opens the Classic lens (unless the hash names one), applies the filters, and shows a dismissible chip naming what the bookmark applied so nothing is silently narrowed. Classic gained the two legacy-only affordances: columns collapse per the ci/dep/ready/dev/cr/qa flags (ids match v1), and closed=1 renders a Recently Closed column using the card anatomy. cryo=1 and repo=SHOWALL map to the existing show-hidden toggle. Plain URLs are untouched; the chip and filters vanish with one click. Six unit tests cover the parser (defaults, lists, SHOWALL, collapse). Co-Authored-By: Claude Fable 5 --- frontend-v2/README.md | 14 ++++ frontend-v2/src/app.tsx | 46 ++++++++++-- frontend-v2/src/legacy.test.ts | 40 ++++++++++ frontend-v2/src/legacy.ts | 107 ++++++++++++++++++++++++++ frontend-v2/src/views/Classic.tsx | 120 ++++++++++++++++++++++++++---- 5 files changed, 308 insertions(+), 19 deletions(-) create mode 100644 frontend-v2/src/legacy.test.ts create mode 100644 frontend-v2/src/legacy.ts diff --git a/frontend-v2/README.md b/frontend-v2/README.md index 55682ad7..d5ff7212 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -45,6 +45,16 @@ lens and the scope presets; without it those features quietly disappear. Long-te in the backend (fetch org teams via the existing Octokit client and ship them with the socket handshake). +## v1 bookmark compatibility + +v1 saved its settings in the query string, and years of bookmarks encode +them. A URL with any v1 param (`repo`, `author`, `cryo`, `drafts`, +`external_block`, `personal`, `closed`, or the `ci`/`dep`/`ready`/`dev`/ +`cr`/`qa` column-collapse flags) opens the Classic lens configured the same +way, v1 defaults included (other people's drafts hidden unless `drafts=1`, +`closed=1` shows the Recently Closed column). A dismissible chip names what +the bookmark applied. `src/legacy.ts` owns the translation. + ## Known gaps (backend additions v2 could use) - **Comment/review activity isn't on the wire** — cues like "X commented 2h @@ -52,3 +62,7 @@ with the socket handshake). - **Teams over the handshake** instead of a static file (see above). - **`changed_files`** is stored in the DB but not sent; the review-weight chip falls back to size-only until it is. +- **CR leaderboard** (v1's leader-list) and **desktop notifications** aren't + ported yet. +- **Repo/author filter UI** — legacy URLs apply these filters, but v2 has no + menu to build them; the scope control covers people, not repos. diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 362b85e0..53afa7c7 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -4,6 +4,7 @@ import { readStorage, writeStorage } from './storage'; import { STATUS_ORDER, type DerivedPull } from './model/status'; import type { Team } from './types'; import { usePulldasher } from './store'; +import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy'; import { loadTeams, useScope } from './prefs'; import { Legend } from './components/Legend'; import { ScopeControl } from './components/Scope'; @@ -57,12 +58,21 @@ export function App() { lastSeen, } = usePulldasher(); const [scope] = useScope(); - const [lens, setLens] = useState(() => readHash().lens); + // a v1 bookmark (?repo=…&author=…&cryo=1…) opens Classic configured the + // same way; the chip below shows what it applied and dismisses it + const [legacy, setLegacy] = useState(() => readLegacyView(location.search)); + const [lens, setLens] = useState(() => { + const h = readHash(); + if (legacy && !location.hash.includes('lens=')) return 'classic'; + return h.lens; + }); const [person, setPerson] = useState(() => readHash().person); const [team, setTeam] = useState(() => readHash().team); const [query, setQuery] = useState(''); const [onlyChanged, setOnlyChanged] = useState(false); - const [showHidden, setShowHidden] = useState(false); + const [showHidden, setShowHidden] = useState( + () => !!legacy && (legacy.cryo || legacy.showAllRepos) + ); const [teams, setTeams] = useState([]); // explicit choice persists; otherwise follow the OS, live const [dark, setDarkState] = useState( @@ -132,11 +142,17 @@ export function App() { // rows the click doesn't deliver const inScope = useMemo(() => { let out = pulls; + if (legacy) out = applyLegacyFilters(out, legacy, me); // v1 conventions: Cryogenic-Storage pulls and hideByDefault repos stay // off the board unless asked for (or the scope names the repo). if (!showHidden) out = out.filter( - p => !p.cryo && (!hiddenRepos.has(p.data.repo) || scope.repos.includes(p.data.repo)) + p => + !p.cryo && + (!hiddenRepos.has(p.data.repo) || + scope.repos.includes(p.data.repo) || + // a legacy URL naming the repo means "show it", hidden or not + legacy?.repos.includes(p.data.repo.replace(/.*\//, ''))) ); if (scope.repos.length) out = out.filter(p => scope.repos.includes(p.data.repo)); // bots bypass the people filter on purpose: dependency bumps need review @@ -155,7 +171,7 @@ export function App() { ); } return out; - }, [pulls, scope, query, showHidden, hiddenRepos]); + }, [pulls, scope, query, showHidden, hiddenRepos, legacy, me]); const scoped = useMemo( () => @@ -273,6 +289,19 @@ export function App() { onChange={e => setQuery(e.target.value)} className="h-8 w-[170px] rounded-lg border border-line bg-surface px-2.5 text-[13px]" /> + {legacy && ( + + )} {onlyChanged && ( + + {open && ( +
+ {children} + {count === 0 && empty && ( +
{empty}
+ )} +
+ )} +
+ ); +} diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index 9a0c3b2a..53376bbf 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -59,13 +59,6 @@ export function Lane({ ); } -export function DividerLine({ label }: { label: string }) { - return ( -
- {label} -
- ); -} /** * The one truncation behavior: show `cap` items and a working "+ N more" diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 951afa38..ec236a3d 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -4,6 +4,7 @@ import { isIterating } from '../model/status'; import { ago } from '../format'; import { refreshPull } from '../store'; import { Avatar, Pips, PullTitleLink, RepoRef, StatusBadge, WeightChip } from './bits'; +import { CardShell } from './Card'; export interface RowOptions { /** hide the status badge when the lane already says it */ @@ -134,43 +135,32 @@ function CompactCard({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const fresh = Date.parse(d.updated_at) / 1000 > opts.lastSeen; const staleCr = pull.recrBy.length > 0; return ( -
- {/* lives in the padding gutter: a changed card must not indent its content */} - {fresh && ( - - )} - - - - - - - - - - - {pull.qaingBy && pull.status === 'needs_qa' && ( - - ◉ - - )} - - - - {pull.ageDays}d + + {pull.qaingBy && pull.status === 'needs_qa' && ( + + ◉ + )} + + + + {pull.ageDays}d - - -
+ + } + /> ); } diff --git a/frontend-v2/src/views/Board.tsx b/frontend-v2/src/views/Board.tsx index 9d2ec12d..8ab1fb4d 100644 --- a/frontend-v2/src/views/Board.tsx +++ b/frontend-v2/src/views/Board.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react'; import { pullKey } from '../format'; import type { DerivedPull, Status } from '../model/status'; import { crSort } from '../model/sort'; import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; +import { BoardColumn } from '../components/Column'; import { Fold, RestGroup } from '../components/Lane'; import { Row, type RowOptions } from '../components/Row'; @@ -35,43 +35,26 @@ function Column({ pulls: DerivedPull[]; opts: RowOptions; }) { - const [open, setOpen] = useState(true); if (!pulls.length) return null; const ordered = ['needs_cr', 'needs_recr'].includes(status) ? crSort(pulls) : pulls; return ( -
-

- -

- {open && ( -
- {ordered.map(p => ( - - ))} -
- )} -
+ + } + > + {ordered.map(p => ( + + ))} + ); } diff --git a/frontend-v2/src/views/Classic.tsx b/frontend-v2/src/views/Classic.tsx index 9be1bef5..6fb765c0 100644 --- a/frontend-v2/src/views/Classic.tsx +++ b/frontend-v2/src/views/Classic.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; import type { DerivedPull } from '../model/status'; import type { PullData } from '../types'; import { ago, pullKey } from '../format'; -import { Avatar, EmptyState, PullTitleLink, RepoRef } from '../components/bits'; +import { EmptyState } from '../components/bits'; +import { CardShell } from '../components/Card'; +import { BoardColumn } from '../components/Column'; import { Row, type RowOptions } from '../components/Row'; /** @@ -62,37 +63,12 @@ function Column({ opts: RowOptions; defaultOpen?: boolean; }) { - const [open, setOpen] = useState(defaultOpen); return ( -
-

- -

- {open && ( -
- {pulls.map(p => ( - - ))} - {!pulls.length &&
none
} -
- )} -
+ + {pulls.map(p => ( + + ))} + ); } @@ -101,29 +77,24 @@ function ClosedCard({ pull }: { pull: PullData }) { const merged = !!pull.merged_at; const closedAt = Date.parse(pull.closed_at ?? pull.updated_at) / 1000; return ( -
- - - - - - - - - - - - {merged ? 'Merged' : 'Closed'} - - {ago(closedAt)} ago + + + {merged ? 'Merged' : 'Closed'} - - -
+ {ago(closedAt)} ago + + } + /> ); } @@ -133,19 +104,11 @@ function ClosedColumn({ pulls }: { pulls: PullData[] }) { (a, b) => Date.parse(b.closed_at ?? b.updated_at) - Date.parse(a.closed_at ?? a.updated_at) ); return ( -
-

- Recently Closed - - {pulls.length} -

-
- {ordered.map(p => ( - - ))} - {!pulls.length &&
none
} -
-
+ + {ordered.map(p => ( + + ))} + ); } From 1b19c3b371bf4a01d0db8fd0ea8469abd7708262 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 16 Jul 2026 23:38:13 -0700 Subject: [PATCH 034/381] Extract Banner and ToggleChip from app.tsx's hand-rolled chrome The three under-header notices shared a copy-pasted positioning and border shell, and the three header pills shared one class string with an active/inactive ternary reimplemented at each site. Banner (tone: bad/warn/brand) and ToggleChip now own them; call sites shrink to their actual content. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 151 ++++++++++++++++++++++++---------------- 1 file changed, 91 insertions(+), 60 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 880be86c..9e5eb8e1 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { ago, n, shortRepo } from './format'; import { readStorage, writeStorage } from './storage'; import { STATUS_ORDER, type DerivedPull } from './model/status'; @@ -45,6 +45,55 @@ const BOT_LOGINS = new Set(['ifixit-systems']); const isBot = (p: DerivedPull) => p.data.user.login.endsWith('[bot]') || BOT_LOGINS.has(p.data.user.login); +/** Full-width notice under the header: bad = red alert, warn = amber, brand = informational. */ +function Banner({ tone, children }: { tone: 'bad' | 'warn' | 'brand'; children: ReactNode }) { + const inner: Record = { + bad: 'border-bad bg-surface text-bad', + warn: 'border-warn bg-surface', + brand: 'notice-inner border-brand bg-brand-50 text-brand-700', + }; + return ( +
+
+ {children} +
+
+ ); +} + +/** Header pill toggle: brand-tinted while active, quiet outline otherwise. */ +function ToggleChip({ + active, + onClick, + title, + className = '', + children, +}: { + active: boolean; + onClick: () => void; + title?: string; + className?: string; + children: ReactNode; +}) { + return ( + + ); +} + export function App() { const { pulls, @@ -290,41 +339,35 @@ export function App() { className="h-8 w-[170px] rounded-lg border border-line bg-surface px-2.5 text-[13px]" /> {legacy && ( - + )} {onlyChanged && ( - + )} {hiddenCount > 0 && ( - + )} {bots.length > 0 && ( @@ -337,53 +380,41 @@ export function App() { {authFailed && ( -
-
- Sign-in failed. - - Your session may have expired.{' '} - - Reload to sign in again - - . - -
-
+ + Sign-in failed. + + Your session may have expired.{' '} + + Reload to sign in again + + . + + )} {initialized && (connection === 'disconnected' || connection === 'error') && ( -
-
- Live updates lost. - - Showing data as of {lastPayloadAt ? `${ago(lastPayloadAt)} ago` : 'page load'}, - retrying in the background. - -
-
+ + Live updates lost. + + Showing data as of {lastPayloadAt ? `${ago(lastPayloadAt)} ago` : 'page load'}, + retrying in the background. + + )} {changedCount > 0 && !query && ( -
-
- - - {n(changedCount, 'PR')} changed since your - last look - - -
-
+ + + + {n(changedCount, 'PR')} changed since your + last look + + + )}
Date: Thu, 16 Jul 2026 23:41:57 -0700 Subject: [PATCH 035/381] Deduplicate the views: FoldRows, byStatus, AnnotatedRow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FoldRows (Lane.tsx) is the standard fold body — capped, expandable rows. Review's local foldRows helper and People's three uncapped hand-rolled maps collapse into it, which also gives People the same "+ N more" truncation every other list already has. - Review recomputed others.filter(status === X) two or three times per fold (count, label pluralization, rows); each status is filtered once now. - MyWork's two lanes duplicated the annotated-row wrapper with the explanation column on opposite sides; AnnotatedRow owns the shape with a side prop. Ledger positions and row counts measure identical to pre-refactor in all five lenses. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Lane.tsx | 19 ++++++++ frontend-v2/src/views/MyWork.tsx | 75 ++++++++++++++++++++--------- frontend-v2/src/views/People.tsx | 17 ++----- frontend-v2/src/views/Review.tsx | 53 ++++++++------------ 4 files changed, 97 insertions(+), 67 deletions(-) diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index 53376bbf..956d8d9e 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -60,6 +60,25 @@ export function Lane({ } +/** The standard fold body: capped, expandable rows for a list of pulls. */ +export function FoldRows({ + list, + opts, + extra, +}: { + list: DerivedPull[]; + opts: RowOptions; + extra?: Partial; +}) { + return ( + + {list.map(p => ( + + ))} + + ); +} + /** * The one truncation behavior: show `cap` items and a working "+ N more" * button. Every capped list in the app goes through this or Lane's own diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index fb99fb4c..2bf74a61 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import type { DerivedPull } from '../model/status'; import type { PullData } from '../types'; import { ago, pullKey } from '../format'; @@ -38,6 +39,52 @@ function waitingOn(p: DerivedPull): string { return 'waiting'; } +/** + * A row with the lane's explanation column beside it: the action label on + * the left in "Your move", the who-to-nudge note on the right in "Waiting + * on others". The row itself drops its cue — the annotation carries it. + */ +function AnnotatedRow({ + pull, + opts, + side, + children, +}: { + pull: DerivedPull; + opts: RowOptions; + side: 'left' | 'right'; + children: ReactNode; +}) { + const row = ( + + + + ); + return ( +
+ {side === 'left' ? ( + <> + + {children} + + {row} + + ) : ( + <> + {row} + + {children} + + + )} +
+ ); +} + export function MyWork({ pulls, closed, @@ -73,17 +120,9 @@ export function MyWork({ opts={opts} > {move.map(p => ( -
- - {yourMove(p)} - - - - -
+ + {yourMove(p)} + ))} {!move.length && (
@@ -99,17 +138,9 @@ export function MyWork({ opts={opts} > {waiting.map(p => ( -
- - - - - {waitingOn(p)} - -
+ + {waitingOn(p)} + ))} {!waiting.length && (
diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx index f5f131d1..cf5163d9 100644 --- a/frontend-v2/src/views/People.tsx +++ b/frontend-v2/src/views/People.tsx @@ -1,10 +1,9 @@ import { useState } from 'react'; -import { pullKey } from '../format'; import { STATUS_ORDER, type DerivedPull } from '../model/status'; import type { Team } from '../types'; import { Avatar } from '../components/bits'; -import { Fold, Lane, RestGroup } from '../components/Lane'; -import { Row, type RowOptions } from '../components/Row'; +import { Fold, FoldRows, Lane, RestGroup } from '../components/Lane'; +import type { RowOptions } from '../components/Row'; import { crSort } from '../model/sort'; /** @@ -206,9 +205,7 @@ export function People({ label="stamped by you" hint="waiting on another reviewer" > - {mine.map(p => ( - - ))} + - {rest.map(p => ( - - ))} + {selectedPerson && ( - {owed.map(p => ( - - ))} + )} diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 98aee9b7..61d3d67a 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -1,10 +1,10 @@ -import type { DerivedPull } from '../model/status'; +import type { DerivedPull, Status } from '../model/status'; import { pullKey } from '../format'; import { crSort } from '../model/sort'; import type { PullData } from '../types'; import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; -import { Fold, Lane, RestGroup, Truncated } from '../components/Lane'; -import { Row, type RowOptions } from '../components/Row'; +import { Fold, FoldRows, Lane, RestGroup, Truncated } from '../components/Lane'; +import type { RowOptions } from '../components/Row'; import { ClosedRow } from '../components/ClosedRow'; /** @@ -53,14 +53,11 @@ export function Review({ ); const stamped = others.filter(p => p.status === 'needs_cr' && p.crBy.includes(me)); - const rowKey = (p: DerivedPull) => `${p.data.repo}#${p.data.number}`; - const foldRows = (list: DerivedPull[], o: Partial = {}) => ( - - {list.map(p => ( - - ))} - - ); + const byStatus = (s: Status) => others.filter(p => p.status === s); + const blocked = byStatus('blocked'); + const ciPending = byStatus('ci_pending'); + const ciRed = byStatus('ci_red'); + const drafts = byStatus('draft'); // bots/shipped stay reachable even when no human PRs need review const empty = !others.length && !bots.length && !closed.length; @@ -110,7 +107,7 @@ export function Review({ label="ready to merge" hint="authors can merge, nudge if idle" > - {foldRows(ready)} + - {foldRows(stamped, { badge: false })} + - p.status === 'blocked').length} - label="blocked" - hint="each row names the holder" - > - {foldRows(others.filter(p => p.status === 'blocked'))} + + p.status === 'ci_pending').length} + count={ciPending.length} label={STATUS_LABEL.ci_pending.toLowerCase()} hint="signed off, waiting on green" > - {foldRows(others.filter(p => p.status === 'ci_pending'))} + - p.status === 'ci_red').length} - label="CI red" - hint="usually the author's fix" - > - {foldRows(others.filter(p => p.status === 'ci_red'))} + + p.status === 'draft').length} - label={others.filter(p => p.status === 'draft').length === 1 ? 'draft' : 'drafts'} + count={drafts.length} + label={drafts.length === 1 ? 'draft' : 'drafts'} hint="not reviewable yet" > - {foldRows(others.filter(p => p.status === 'draft'))} + - {foldRows(bots)} + Date: Thu, 16 Jul 2026 23:52:47 -0700 Subject: [PATCH 036/381] Nothing deployment-specific in the source: config-driven bots Pulldasher is public and generic; the frontend shouldn't know who deploys it. A sweep of the v2 branch found no secrets, hostnames, or internal URLs, but two org-specific hardcodes: - BOT_LOGINS pinned one company's machine account in app.tsx. Bot detection is now the GitHub [bot] suffix plus a `bots` list in the deployment config; teams.json grows into config.json ({teams, bots}, gitignored, with a generic config.example.json). - The styles.css token comment described one org's brand rather than the tokens; it now explains how to retheme. Repo names in test fixtures and the shared v1 dummy fixture stay as sample data on purpose. Co-Authored-By: Claude Fable 5 --- .gitignore | 2 +- frontend-v2/README.md | 16 +++++---- frontend-v2/public/config.example.json | 7 ++++ frontend-v2/public/teams.example.json | 4 --- frontend-v2/src/app.tsx | 21 ++++++++---- frontend-v2/src/prefs.ts | 46 ++++++++++++++++---------- frontend-v2/src/styles.css | 5 +-- 7 files changed, 65 insertions(+), 36 deletions(-) create mode 100644 frontend-v2/public/config.example.json delete mode 100644 frontend-v2/public/teams.example.json diff --git a/.gitignore b/.gitignore index a5dc6f6b..ddc67c68 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ pulldasher.pid dist db_data/ /.env.db -frontend-v2/public/teams.json +frontend-v2/public/config.json diff --git a/frontend-v2/README.md b/frontend-v2/README.md index d5ff7212..60620589 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -37,13 +37,17 @@ npm run build # type-check + bundle to frontend-v2/dist (served at /v2) npm test # vitest: status-derivation and CI-verdict unit tests ``` -## Teams config +## Site config -Copy `public/teams.example.json` to `public/teams.json` (gitignored) and list -GitHub team slugs with member logins. It powers the team chips on the People -lens and the scope presets; without it those features quietly disappear. Long-term this belongs -in the backend (fetch org teams via the existing Octokit client and ship them -with the socket handshake). +Copy `public/config.example.json` to `public/config.json` (gitignored) for +deployment-specific settings: + +- `teams` — GitHub team slugs with member logins. Powers the team chips on + the People lens and the scope presets; without it those features quietly + disappear. Long-term this belongs in the backend (fetch org teams via the + existing Octokit client and ship them with the socket handshake). +- `bots` — machine-account logins to fold into the bot-PRs group. Accounts + with GitHub's `[bot]` suffix are detected without config. ## v1 bookmark compatibility diff --git a/frontend-v2/public/config.example.json b/frontend-v2/public/config.example.json new file mode 100644 index 00000000..f3512fdd --- /dev/null +++ b/frontend-v2/public/config.example.json @@ -0,0 +1,7 @@ +{ + "teams": [ + { "team": "frontend", "members": ["alice", "bob"] }, + { "team": "backend", "members": ["carol"] } + ], + "bots": ["renovate-bot"] +} diff --git a/frontend-v2/public/teams.example.json b/frontend-v2/public/teams.example.json deleted file mode 100644 index 5bbc6f7e..00000000 --- a/frontend-v2/public/teams.example.json +++ /dev/null @@ -1,4 +0,0 @@ -[ - { "team": "carpenter", "members": ["alice", "bob"] }, - { "team": "nextjs", "members": ["carol"] } -] diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 9e5eb8e1..1ef4b5f3 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -5,7 +5,7 @@ import { STATUS_ORDER, type DerivedPull } from './model/status'; import type { Team } from './types'; import { usePulldasher } from './store'; import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy'; -import { loadTeams, useScope } from './prefs'; +import { loadSiteConfig, useScope } from './prefs'; import { Legend } from './components/Legend'; import { ScopeControl } from './components/Scope'; import { STATUS_LABEL } from './components/bits'; @@ -41,9 +41,10 @@ function writeHash(lens: Lens, person: string | null, team: string | null) { } const THEME_KEY = 'pd2.theme'; -const BOT_LOGINS = new Set(['ifixit-systems']); -const isBot = (p: DerivedPull) => - p.data.user.login.endsWith('[bot]') || BOT_LOGINS.has(p.data.user.login); +// GitHub Apps carry a [bot] suffix; other machine accounts are named in +// config.json's `bots` list. +const isBotLogin = (login: string, extra: ReadonlySet) => + login.endsWith('[bot]') || extra.has(login); /** Full-width notice under the header: bad = red alert, warn = amber, brand = informational. */ function Banner({ tone, children }: { tone: 'bad' | 'warn' | 'brand'; children: ReactNode }) { @@ -123,6 +124,11 @@ export function App() { () => !!legacy && (legacy.cryo || legacy.showAllRepos) ); const [teams, setTeams] = useState([]); + const [extraBots, setExtraBots] = useState>(new Set()); + const isBot = useCallback( + (p: DerivedPull) => isBotLogin(p.data.user.login, extraBots), + [extraBots] + ); // explicit choice persists; otherwise follow the OS, live const [dark, setDarkState] = useState( () => @@ -136,7 +142,10 @@ export function App() { const searchRef = useRef(null); useEffect(() => { - void loadTeams().then(setTeams); + void loadSiteConfig().then(c => { + setExtraBots(new Set(c.bots)); + setTeams(c.teams); + }); }, []); useEffect(() => { writeHash(lens, person, team); @@ -220,7 +229,7 @@ export function App() { ); } return out; - }, [pulls, scope, query, showHidden, hiddenRepos, legacy, me]); + }, [pulls, scope, query, showHidden, hiddenRepos, legacy, me, isBot]); const scoped = useMemo( () => diff --git a/frontend-v2/src/prefs.ts b/frontend-v2/src/prefs.ts index 35a98ee7..61314419 100644 --- a/frontend-v2/src/prefs.ts +++ b/frontend-v2/src/prefs.ts @@ -46,26 +46,38 @@ export function useScope(): [Scope, (next: Scope) => void] { return [value, setScope]; } +export interface SiteConfig { + /** team → members, for the People lens chips and scope presets */ + teams: Team[]; + /** bot logins beyond the `[bot]` suffix GitHub Apps carry */ + bots: string[]; +} + /** - * Team → members config for the Teams lens and the scope presets. Served as - * a static file next to the app (see teams.example.json); absent file means - * no team features, everything else still works. + * Deployment-specific config, served as a static file next to the app (see + * config.example.json). Absent file means no team features and suffix-only + * bot detection; everything else still works. */ -export async function loadTeams(): Promise { +export async function loadSiteConfig(): Promise { + const none: SiteConfig = { teams: [], bots: [] }; try { - const res = await fetch(`${import.meta.env.BASE_URL}teams.json`); - if (!res.ok) return []; - const teams: unknown = await res.json(); - if (!Array.isArray(teams)) return []; - // one malformed entry must not white-screen the whole app - return teams.filter( - (t): t is Team => - !!t && - typeof (t as Team).team === 'string' && - Array.isArray((t as Team).members) && - (t as Team).members.every(m => typeof m === 'string') - ); + const res = await fetch(`${import.meta.env.BASE_URL}config.json`); + if (!res.ok) return none; + const raw = (await res.json()) as { teams?: unknown; bots?: unknown }; + const teams = Array.isArray(raw.teams) ? raw.teams : []; + const bots = Array.isArray(raw.bots) ? raw.bots : []; + return { + // one malformed entry must not white-screen the whole app + teams: teams.filter( + (t): t is Team => + !!t && + typeof (t as Team).team === 'string' && + Array.isArray((t as Team).members) && + (t as Team).members.every(m => typeof m === 'string') + ), + bots: bots.filter((b): b is string => typeof b === 'string'), + }; } catch { - return []; + return none; } } diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index e13ffb13..d97db16a 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -1,8 +1,9 @@ @import "tailwindcss"; -/* ── iFixit house tokens: gray-ramp neutrals, brand blue as the only action +/* ── Design tokens: gray-ramp neutrals, one brand blue as the only action color, status badges as 200-tint bg / 900-step text. The dark board keeps - pulldasher's night-shift habit, derived from the same gray ramp. ── */ + pulldasher's night-shift habit, derived from the same gray ramp. Retheme + by swapping --brand* and the badge pairs. ── */ :root { --canvas: #f9fafb; --surface: #ffffff; From e0352b1d5fb92f70523151193d14e736b8cd4709 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 00:27:49 -0700 Subject: [PATCH 037/381] One status per situation, and a queue that ranks the right thing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A UX critique pass (two independent reviews plus real-repo PR stats) found the model optimizing the wrong scarcities. The fixes, in one coherent model change: - Split the catch-all 'blocked' status three ways. dev_block is the author's move (changes requested); deploy_block is done-but-held and now gates only the late stages instead of hiding pulls from review; unmergeable is signed-off-but-conflicted, the author rebases. The old single badge told a deployer nothing about what was safe to ship and told a dev-blocked author to nudge the wrong person. - Track stale QA stamps symmetrically with stale CR (reqaBy), so an invalidated QA stamp ties back to its owner instead of silently returning the pull to the general pool. - Widen starvation past the zero-stamp cliff: a 1-of-2-CR pull rotting for 30 days now ages like the rest. Real-repo data: 9 PRs untouched >30d, most with partial review, none previously 'starved'. - Rank the review queue by leverage and age, not just weight: one stamp from done jumps ahead (your stamp finishes CR instead of starting it), age earns up to two weight classes, and unknown-size pulls stop ranking as XS-first. - Name the failing CI checks (ciFailing) and the sign-off moment (signedOffAt) so 'CI red' and 'ready, nudge if idle' can say which and how long. - Merge the Board lens into Classic: it was Review's content in Classic's shape with neither's justification. Old #lens=board links redirect. - Review opens with 'Yours to do': every action that's yours (re-stamps owed, your merge buttons, CI fixes) regardless of author — the daily loop no longer spans two tabs. Needs QA became a query (QA runs in parallel with CR, v1's own predicate) with your in-flight QA pinned. - Ledger: partial staleness shows as 1/2⊘ instead of hiding in a plain fraction; a dotted underline marks slots you stamped; clicking a slot lists who signed and when (v1's signer bubbles, reborn); the age slot heats amber past a week and red past two with both clocks on hover. - Light-mode ok/warn/bad darkened to text-grade AA for the 12px marks. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 16 +- frontend-v2/src/components/Lane.tsx | 87 +++++++--- frontend-v2/src/components/Legend.tsx | 71 ++++---- frontend-v2/src/components/Row.tsx | 133 ++++++++++----- frontend-v2/src/components/Scope.tsx | 34 ++-- frontend-v2/src/components/bits.tsx | 199 ++++++++++++++++++++--- frontend-v2/src/components/usePopover.ts | 35 ++++ frontend-v2/src/model/actions.ts | 29 ++++ frontend-v2/src/model/sort.test.ts | 61 +++++++ frontend-v2/src/model/sort.ts | 29 +++- frontend-v2/src/model/status.test.ts | 76 +++++++-- frontend-v2/src/model/status.ts | 149 ++++++++++++----- frontend-v2/src/styles.css | 14 +- frontend-v2/src/views/Board.tsx | 99 ----------- frontend-v2/src/views/Classic.tsx | 14 +- frontend-v2/src/views/MyWork.tsx | 82 ++-------- frontend-v2/src/views/People.tsx | 13 ++ frontend-v2/src/views/Review.tsx | 150 +++++++++++++---- 18 files changed, 870 insertions(+), 421 deletions(-) create mode 100644 frontend-v2/src/components/usePopover.ts create mode 100644 frontend-v2/src/model/actions.ts create mode 100644 frontend-v2/src/model/sort.test.ts delete mode 100644 frontend-v2/src/views/Board.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 1ef4b5f3..b8cc4d25 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -13,17 +13,19 @@ import type { RowOptions } from './components/Row'; import { Review } from './views/Review'; import { MyWork } from './views/MyWork'; import { People } from './views/People'; -import { Board } from './views/Board'; import { Classic } from './views/Classic'; -type Lens = 'review' | 'mine' | 'people' | 'board' | 'classic'; +type Lens = 'review' | 'mine' | 'people' | 'classic'; -const LENSES: Lens[] = ['review', 'mine', 'people', 'board', 'classic']; +const LENSES: Lens[] = ['review', 'mine', 'people', 'classic']; /** Lens and drill-down selections live in the hash: shareable, bookmarkable. */ function readHash() { const p = new URLSearchParams(location.hash.slice(1)); - const lens = p.get('lens') as Lens | null; + let lens = p.get('lens') as Lens | null; + // the Board lens merged into Classic (same columns, real justification); + // old #lens=board links keep working + if ((lens as string) === 'board') lens = 'classic'; return { lens: lens && LENSES.includes(lens) ? lens : ('review' as Lens), person: p.get('person'), @@ -334,7 +336,6 @@ export function App() { {tab('review', 'Review')} {tab('mine', 'My work', mineCount)} {tab('people', 'People')} - {tab('board', 'Board')} {tab('classic', 'Classic')} @@ -413,8 +414,8 @@ export function App() { - {n(changedCount, 'PR')} changed since your - last look + {n(changedCount, 'PR')} changed since your last + look - )} + + {pulls.map(p => ( + + ))} + ); } +/** + * A row with the lane's explanation column beside it: the action verb on + * the left ("Merge it", "Re-stamp"), or the who-to-nudge note on the right. + * The row itself drops its cue — the annotation carries it. + */ +export function AnnotatedRow({ + pull, + opts, + side, + children, +}: { + pull: DerivedPull; + opts: RowOptions; + side: 'left' | 'right'; + children: ReactNode; +}) { + const row = ( + + + + ); + return ( +
+ {side === 'left' ? ( + <> + + {children} + + {row} + + ) : ( + <> + {row} + + {children} + + + )} +
+ ); +} /** The standard fold body: capped, expandable rows for a list of pulls. */ export function FoldRows({ @@ -79,21 +114,33 @@ export function FoldRows({ ); } +// Expansion survives lens switches: re-expanding the same "+N more" on every +// tab visit, hundreds of times a day, is pure friction. Session-local on +// purpose — a fresh visit starts folded again. +const expandedIds = new Set(); + /** * The one truncation behavior: show `cap` items and a working "+ N more" - * button. Every capped list in the app goes through this or Lane's own - * more-line — a count the user can see but not open is a lie. + * button. Every capped list in the app goes through this — a count the user + * can see but not open is a lie. */ export function Truncated({ children, cap = 30, label = 'more', + id, }: { children: ReactNode[]; cap?: number; label?: string; + /** stable identity: remembers expansion across unmounts this session */ + id?: string; }) { - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(id ? expandedIds.has(id) : false); + const expand = () => { + setExpanded(true); + if (id) expandedIds.add(id); + }; const shown = expanded ? children : children.slice(0, cap); const more = children.length - shown.length; return ( @@ -102,7 +149,7 @@ export function Truncated({ {more > 0 && ( - {open && ( + {pop.open && ( Reading the board {item( @@ -64,11 +46,24 @@ export function Legend() { CR 1/2 , - 'sign-offs given / required; ✓ done, – not required' + 'sign-offs given / required; ✓ done, – not required. Click for who signed' + )} + {item( + + 1/2 + , + 'a push invalidated a stamp: someone owes a re-stamp' + )} + {item( + 1/2, + 'the dotted underline marks a slot you stamped' )} {item( - , - 'a push invalidated the stamp: it needs re-CR' + + 9d/ + 15d + , + 'age heats up: amber past a week, red past two. Hover for both clocks' )} {item( M, @@ -76,15 +71,23 @@ export function Legend() { )} {item( QAing, - 'someone claimed QA by adding the QAing label on GitHub' + 'someone claimed QA by adding the QAing label on GitHub (◉ in columns)' )} {item( iterating, 'changed in the last 30 minutes, may still be moving' )} {item( - Blocked, - 'a dev/deploy block signature: ask the holder to lift it' + Dev blocked, + 'a reviewer requested changes: the author’s move' + )} + {item( + Deploy hold, + 'done, deliberately held from shipping: ask the holder' + )} + {item( + Can't merge, + 'signed off but conflicted or on an unmerged parent: author rebases' )} {item(, 'changed since your last look')} {item(, 'Cryogenic Storage PRs and quiet repos, hidden by default')} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index ec236a3d..ee1339c7 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -3,7 +3,7 @@ import type { DerivedPull } from '../model/status'; import { isIterating } from '../model/status'; import { ago } from '../format'; import { refreshPull } from '../store'; -import { Avatar, Pips, PullTitleLink, RepoRef, StatusBadge, WeightChip } from './bits'; +import { AgeStamp, Avatar, PullTitleLink, RepoRef, SigPips, StatusBadge, WeightChip } from './bits'; import { CardShell } from './Card'; export interface RowOptions { @@ -12,9 +12,10 @@ export interface RowOptions { /** show the open-Nd flag on starved pulls */ aging?: boolean; /** - * Column mode (Board/Classic): the title owns the space. Drops the cue - * line, warning flags, weight chip, and hover actions — a full row's - * anatomy crushes titles to two letters inside a 340px column. + * Column mode (Classic): the title owns the space. Drops the cue line, + * weight chip, and hover actions — a full row's anatomy crushes titles + * to two letters inside a 340px column. Warning flags stay: conflicts + * and holds change who acts, and columns are where deployers look. */ compact?: boolean; /** suppress the cue line when the view says the same thing beside the row */ @@ -31,30 +32,54 @@ function cue(p: DerivedPull, me: string): string | null { const wait = p.headPushedAt ? ` · fix pushed ${ago(p.headPushedAt)} ago` : ''; return `CR’d by ${who}${wait}`; } - if (p.status === 'ready') - return `signed off and green, ${d.user.login === me ? 'you' : d.user.login} can merge`; - if (p.status === 'needs_qa') - return p.qaingBy ? `${p.qaingBy} is QAing` : 'CR done, needs a QA stamp'; + if (p.status === 'ready') { + const idle = p.signedOffAt ? `signed off ${ago(p.signedOffAt)} ago, ` : ''; + return `${idle}${d.user.login === me ? 'you' : d.user.login} can merge`; + } + if (p.status === 'needs_qa') { + if (p.qaingBy) return `${p.qaingBy} is QAing`; + if (p.reqaBy.length) { + const who = p.reqaBy.includes(me) ? 'your QA stamp' : `${p.reqaBy.join(', ')}’s QA stamp`; + return `${who} was invalidated by a push`; + } + return 'CR done, needs a QA stamp'; + } if (p.status === 'ci_pending') return 'signed off, only CI left'; - if (p.status === 'blocked' && p.blockedBy.length) return `blocked by ${p.blockedBy.join(', ')}`; + if (p.status === 'ci_red' && p.ciFailing.length) return `red: ${p.ciFailing.join(', ')}`; + if (p.status === 'dev_block' && p.devBlockedBy.length) + return `changes requested by ${p.devBlockedBy.join(', ')}`; + if (p.status === 'deploy_block' && p.deployBlockedBy.length) + return `held from deploy by ${p.deployBlockedBy.join(', ')}`; + if (p.status === 'unmergeable') + return p.conflict + ? 'signed off, but conflicts — the author rebases' + : `based on ${d.base.ref}, lands with its parent`; return null; } /** Small warning flags that ride along regardless of lane. */ -function WarnFlags({ pull }: { pull: DerivedPull }) { +function WarnFlags({ pull, compact }: { pull: DerivedPull; compact?: boolean }) { return ( <> - {pull.conflict && ( + {pull.conflict && pull.status !== 'unmergeable' && ( conflicts )} - {pull.dependent && ( + {pull.dependent && pull.status !== 'unmergeable' && ( - dependent + {compact ? 'dep' : 'dependent'} + + )} + {pull.deployBlockedBy.length > 0 && pull.status !== 'deploy_block' && ( + + hold )} {pull.mergeUnknown && pull.status === 'ready' && ( @@ -62,14 +87,14 @@ function WarnFlags({ pull }: { pull: DerivedPull }) { mergeable? )} - {pull.ci === 'pending' && pull.status !== 'ci_pending' && ( + {!compact && pull.ci === 'pending' && pull.status !== 'ci_pending' && ( CI… )} {pull.externalBlock && ( - external + {compact ? 'ext' : 'external'} )} {pull.qaingBy && pull.status === 'needs_qa' && ( @@ -77,7 +102,7 @@ function WarnFlags({ pull }: { pull: DerivedPull }) { className="flag-qaing" title={`${pull.qaingBy} is already testing this (the QAing label)`} > - QAing: {pull.qaingBy} + {compact ? `◉ ${pull.qaingBy}` : `QAing: ${pull.qaingBy}`} )} @@ -123,17 +148,48 @@ function RowActions({ pull }: { pull: DerivedPull }) { ); } +/** The CR/QA/age cluster both row variants render, in identical geometry. */ +function Ledger({ pull, me }: { pull: DerivedPull; me: string }) { + const d = pull.data; + return ( + <> + + + + + ); +} + /** - * Column card (Board/Classic): two zones instead of one fought-over line. + * Column card (Classic): two zones instead of one fought-over line. * Zone 1 is identity — avatar plus the title at full text width, so a long * title wraps twice instead of five times against a dead metadata column. - * Zone 2 is one fact line — repo#number, pips, age pushed to the right edge - * so ages scan as a vertical column down the board. + * Zone 2 is one fact line — repo#number, flags, pips, age pushed to the + * right edge so ages scan as a vertical column down the board. */ function CompactCard({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const d = pull.data; const fresh = Date.parse(d.updated_at) / 1000 > opts.lastSeen; - const staleCr = pull.recrBy.length > 0; return ( - {pull.qaingBy && pull.status === 'needs_qa' && ( - - ◉ - - )} - - - - {pull.ageDays}d - + + } /> @@ -171,7 +216,6 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { if (compact) return ; const showWeight = pull.sizeKnown && ['needs_cr', 'needs_recr'].includes(pull.status); - const staleCr = pull.recrBy.length > 0; const line = opts.cue === false ? null : cue(pull, opts.me); return ( @@ -182,6 +226,8 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { {fresh && ( )} @@ -198,27 +244,26 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { )} {opts.aging && pull.starved && ( - + open {pull.ageDays}d )} {line && ( - + {line} )} {showWeight && } - - - - {pull.ageDays}d - +
); diff --git a/frontend-v2/src/components/Scope.tsx b/frontend-v2/src/components/Scope.tsx index 019dbc59..28ce6fa3 100644 --- a/frontend-v2/src/components/Scope.tsx +++ b/frontend-v2/src/components/Scope.tsx @@ -1,8 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; import type { DerivedPull } from '../model/status'; import type { Team } from '../types'; import { shortRepo } from '../format'; import { useScope, type Scope } from '../prefs'; +import { usePopover } from './usePopover'; /** * The one filter control: a popover with team presets, people, and repos. @@ -11,27 +11,13 @@ import { useScope, type Scope } from '../prefs'; */ export function ScopeControl({ pulls, teams }: { pulls: DerivedPull[]; teams: Team[] }) { const [scope, setScope] = useScope(); - const [open, setOpen] = useState(false); - const ref = useRef(null); - const triggerRef = useRef(null); - - useEffect(() => { - if (!open) return; - const clickAway = (e: MouseEvent) => { - if (!ref.current?.contains(e.target as Node)) setOpen(false); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key !== 'Escape') return; - setOpen(false); - triggerRef.current?.focus(); - }; - document.addEventListener('click', clickAway); - document.addEventListener('keydown', onKey); - return () => { - document.removeEventListener('click', clickAway); - document.removeEventListener('keydown', onKey); - }; - }, [open]); + const { + open, + setOpen, + rootRef: ref, + panelRef, + triggerRef, + } = usePopover(); const repoCounts = new Map(); const authorCounts = new Map(); @@ -140,9 +126,11 @@ export function ScopeControl({ pulls, teams }: { pulls: DerivedPull[]; teams: Te )} {open && ( + {pop.open && ( + + {label} stamps + {rows.map(s => ( + + + {s.data.user.login} + {s.data.user.login === me && (you)} + + {ago(Date.parse(s.data.created_at) / 1000)} ago + + {s.data.active ? ( + + ✓ + + ) : ( + + ⊘ + + )} + + ))} + + )} + + ); +} + +/** + * The age slot: open-days with an urgency ramp (quiet under a week, amber + * past STARVE_DAYS, red past two) and both clocks in the tooltip — a 30-day + * pull pushed an hour ago is hot, and the flat gray number hid that. + */ +export function AgeStamp({ + ageDays, + updatedAt, + quiet, +}: { + ageDays: number; + /** epoch secs of the last activity */ + updatedAt: number; + /** drafts and holds age on purpose: no urgency color */ + quiet?: boolean; +}) { + const hot = quiet ? null : ageDays >= 14 ? 'var(--bad)' : ageDays >= 7 ? 'var(--warn)' : null; + return ( + + {ageDays}d + + ); +} + /** The full-title GitHub link every row variant renders — never truncated. */ export function PullTitleLink({ repo, diff --git a/frontend-v2/src/components/usePopover.ts b/frontend-v2/src/components/usePopover.ts new file mode 100644 index 00000000..d7c3810e --- /dev/null +++ b/frontend-v2/src/components/usePopover.ts @@ -0,0 +1,35 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * The house popover discipline, shared by Legend, Scope, and the signature + * ledger: click-away closes, Escape closes and returns focus to the trigger, + * and opening moves focus to the panel so keyboard users land inside the + * dialog they just opened. + */ +export function usePopover() { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const panelRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!open) return; + panelRef.current?.focus(); + const clickAway = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + setOpen(false); + triggerRef.current?.focus(); + }; + document.addEventListener('click', clickAway); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('click', clickAway); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + return { open, setOpen, rootRef, panelRef, triggerRef }; +} diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts new file mode 100644 index 00000000..1246fb5e --- /dev/null +++ b/frontend-v2/src/model/actions.ts @@ -0,0 +1,29 @@ +import type { DerivedPull } from './status'; + +/** + * The verb column: what moves this pull, and whose move is it? Review's + * "Yours to do" lane and My work's "Your move" both read from here so the + * two tabs can never disagree about what you owe. + */ + +/** Your move on a pull you authored; null = waiting on someone else. */ +export function authorMove(p: DerivedPull): string | null { + if (p.status === 'ready') return 'Merge it'; + if (p.status === 'ci_red') return 'Fix CI'; + // a dev block is feedback waiting on YOU — v1 lore said "ask them to lift + // it", which misroutes the most common author action + if (p.status === 'dev_block') return 'Address feedback'; + if (p.status === 'unmergeable' || p.conflict) return 'Rebase'; + if (p.status === 'needs_qa' && !p.qaingBy && !p.reqaBy.length) return 'Find a QA-er'; + if (p.status === 'draft') return 'Finish the draft'; + return null; +} + +/** Your move on someone else's pull; null = not your job right now. */ +export function reviewerMove(p: DerivedPull, me: string): string | null { + if (p.data.user.login === me) return null; + if (p.status === 'needs_recr' && p.recrBy.includes(me)) return 'Re-stamp'; + if (p.status === 'needs_qa' && p.qaingBy === me) return 'Finish QA'; + if (p.status === 'needs_qa' && p.reqaBy.includes(me)) return 'Re-QA'; + return null; +} diff --git a/frontend-v2/src/model/sort.test.ts b/frontend-v2/src/model/sort.test.ts new file mode 100644 index 00000000..4faa5bdf --- /dev/null +++ b/frontend-v2/src/model/sort.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import type { DerivedPull } from './status'; +import { crScore, crSort } from './sort'; + +function fake(over: { + ageDays?: number; + crHave?: number; + crReq?: number; + additions?: number | null; + weight?: DerivedPull['weight']; + sizeKnown?: boolean; +}): DerivedPull { + const additions = over.additions === undefined ? 100 : over.additions; + return { + ageDays: over.ageDays ?? 0, + crHave: over.crHave ?? 0, + weight: over.weight ?? 'S', + sizeKnown: over.sizeKnown ?? true, + data: { + additions, + deletions: 0, + // ancient updated_at so nothing counts as iterating + updated_at: new Date(0).toISOString(), + status: { cr_req: over.crReq ?? 1 }, + }, + } as unknown as DerivedPull; +} + +describe('crScore / crSort', () => { + it('a pull one stamp from done outranks an equal-weight untouched one', () => { + const oneFromDone = fake({ crHave: 1, crReq: 2 }); + const untouched = fake({ crHave: 0, crReq: 2 }); + expect(crScore(oneFromDone)).toBeLessThan(crScore(untouched)); + }); + + it('age earns rank: an old M beats a fresh M and can beat a fresh S', () => { + const oldM = fake({ weight: 'M', ageDays: 10 }); + const freshM = fake({ weight: 'M', ageDays: 0 }); + const freshS = fake({ weight: 'S', ageDays: 0 }); + expect(crScore(oldM)).toBeLessThan(crScore(freshM)); + expect(crScore(oldM)).toBeLessThan(crScore(freshS)); + }); + + it('unknown size ranks as a medium guess, never XS-first', () => { + const unknown = fake({ sizeKnown: false, weight: 'XS', additions: null }); + const knownXs = fake({ weight: 'XS' }); + const knownL = fake({ weight: 'L' }); + expect(crScore(unknown)).toBeGreaterThan(crScore(knownXs)); + expect(crScore(unknown)).toBeLessThan(crScore(knownL)); + }); + + it('sorts by score, then oldest first', () => { + const a = fake({ weight: 'S', ageDays: 10 }); + const b = fake({ weight: 'S', ageDays: 2 }); + const c = fake({ weight: 'XS', ageDays: 10 }); + const sorted = crSort([b, a, c]); + expect(sorted[0]).toBe(c); + expect(sorted[1]).toBe(a); + expect(sorted[2]).toBe(b); + }); +}); diff --git a/frontend-v2/src/model/sort.ts b/frontend-v2/src/model/sort.ts index 28ea9886..3064de2f 100644 --- a/frontend-v2/src/model/sort.ts +++ b/frontend-v2/src/model/sort.ts @@ -1,11 +1,34 @@ -import { isIterating, weightRank, type DerivedPull } from './status'; +import { STARVE_DAYS, isIterating, weightRank, type DerivedPull } from './status'; -/** lightest first; actively-iterating pulls sink (demoted, never hidden) */ +/** + * The review-queue score, lower first. Weight is the base (lightest first — + * a 10-minute reviewer takes what fits), then two corrections the old sort + * lacked: + * + * - leverage: a pull one stamp from done jumps ~1.5 weight classes. Your + * stamp there finishes CR instead of starting it. + * - age: up to two weight classes of credit as a pull approaches two weeks, + * so an old M outranks a fresh S instead of waiting for the starvation + * cliff. + * + * Unknown size ranks as M-ish, not XS: missing data must not promote a pull + * to the top of everyone's queue. + */ +export function crScore(p: DerivedPull): number { + const weight = p.sizeKnown ? weightRank(p.weight) : 2.5; + const req = p.data.status.cr_req; + const oneFromDone = p.crHave > 0 && req - p.crHave === 1; + const ageCredit = Math.min(p.ageDays / STARVE_DAYS, 2); + return weight - (oneFromDone ? 1.5 : 0) - ageCredit; +} + +/** best next review first; actively-iterating pulls sink (demoted, never hidden) */ export function crSort(pulls: DerivedPull[]): DerivedPull[] { return [...pulls].sort( (a, b) => Number(isIterating(a.data)) - Number(isIterating(b.data)) || - weightRank(a.weight) - weightRank(b.weight) || + crScore(a) - crScore(b) || + b.ageDays - a.ageDays || (a.data.additions ?? 0) + (a.data.deletions ?? 0) - ((b.data.additions ?? 0) + (b.data.deletions ?? 0)) diff --git a/frontend-v2/src/model/status.test.ts b/frontend-v2/src/model/status.test.ts index d80baec3..1377d6c2 100644 --- a/frontend-v2/src/model/status.test.ts +++ b/frontend-v2/src/model/status.test.ts @@ -75,12 +75,29 @@ describe('status derivation precedence', () => { expect(derive(p, undefined, NOW).status).toBe('draft'); }); - it('blocked wins over ci_red', () => { + it('a dev block wins over ci_red: the author owes changes first', () => { const p = withStatus({ + dev_block: [sig('dev_block', 'x', true)], + commit_statuses: [ci('failure')], + }); + expect(derive(p, undefined, NOW).status).toBe('dev_block'); + }); + + it('a deploy block gates only the late stages, never review or CI', () => { + const red = withStatus({ deploy_block: [sig('deploy_block', 'x', true)], commit_statuses: [ci('failure')], }); - expect(derive(p, undefined, NOW).status).toBe('blocked'); + expect(derive(red, undefined, NOW).status).toBe('ci_red'); + + const held = withStatus({ + deploy_block: [sig('deploy_block', 'ops', true)], + allCR: [sig('CR', 'r', true)], + allQA: [sig('QA', 'q', true)], + }); + const d = derive(held, undefined, NOW); + expect(d.status).toBe('deploy_block'); + expect(d.deployBlockedBy).toEqual(['ops']); }); it('failing CI beats the review lanes', () => { @@ -146,7 +163,7 @@ describe('status derivation precedence', () => { ); const d = derive(conflicted, undefined, NOW); expect(d.conflict).toBe(true); - expect(d.status).toBe('blocked'); + expect(d.status).toBe('unmergeable'); const dependent = withStatus( { allCR: [sig('CR', 'r', true)], allQA: [sig('QA', 'q', true)] }, @@ -154,7 +171,7 @@ describe('status derivation precedence', () => { ); const dd = derive(dependent, undefined, NOW); expect(dd.dependent).toBe(true); - expect(dd.status).toBe('blocked'); + expect(dd.status).toBe('unmergeable'); // still reviewable while CR is missing: conflict is a flag, not a gate const needsCr = pull({ mergeable: false }); @@ -186,19 +203,32 @@ describe('status derivation precedence', () => { expect(d.externalBlock).toBe(false); }); - it('names everyone holding a block', () => { + it('names each kind of block holder separately', () => { const p = withStatus({ dev_block: [sig('dev_block', 'holder', true)], deploy_block: [sig('deploy_block', 'ops', true)], }); - expect(derive(p, undefined, NOW).blockedBy).toEqual(['holder', 'ops']); + const d = derive(p, undefined, NOW); + expect(d.devBlockedBy).toEqual(['holder']); + expect(d.deployBlockedBy).toEqual(['ops']); + expect(d.status).toBe('dev_block'); }); it('a lifted (inactive) block no longer blocks', () => { const p = withStatus({ dev_block: [sig('dev_block', 'holder', false)] }); const d = derive(p, undefined, NOW); expect(d.status).toBe('needs_cr'); - expect(d.blockedBy).toEqual([]); + expect(d.devBlockedBy).toEqual([]); + }); + + it('tracks stale QA stamps symmetrically with stale CR', () => { + const p = withStatus({ + allCR: [sig('CR', 'r', true)], + allQA: [sig('QA', 'tester', false)], + }); + const d = derive(p, undefined, NOW); + expect(d.status).toBe('needs_qa'); + expect(d.reqaBy).toEqual(['tester']); }); it('unknown mergeability is flagged, not asserted', () => { @@ -261,15 +291,41 @@ describe('ciVerdict', () => { }); describe('starvation and weight', () => { - it('an old un-CRed pull is starved; a reviewed one never is', () => { + it('an old CR-incomplete pull is starved, whatever partial progress it has', () => { const old = new Date((NOW - 10 * 86400) * 1000).toISOString(); const p = pull({ created_at: old }); const d = derive(p, undefined, NOW); expect(d.starved).toBe(true); expect(d.starveScore).toBeGreaterThan(0); - const reviewed = withStatus({ allCR: [sig('CR', 'r', false)] }, { created_at: old }); - expect(derive(reviewed, undefined, NOW).starved).toBe(false); + // half-reviewed rot: 1 of 2 CRs, still starving (the old crHave === 0 + // cliff hid these from the aging lane forever) + const half = withStatus({ cr_req: 2, allCR: [sig('CR', 'r', true)] }, { created_at: old }); + expect(derive(half, undefined, NOW).starved).toBe(true); + + // a stale stamp waiting on its re-CR rots too + const stale = withStatus({ allCR: [sig('CR', 'r', false)] }, { created_at: old }); + expect(derive(stale, undefined, NOW).starved).toBe(true); + + // fully CRed: never starved + const done = withStatus({ allCR: [sig('CR', 'r', true)] }, { created_at: old }); + expect(derive(done, undefined, NOW).starved).toBe(false); + }); + + it('records when the last required sign-off landed', () => { + const p = withStatus({ + allCR: [sig('CR', 'r', true, '2026-01-02T00:00:00Z')], + allQA: [sig('QA', 'q', true, '2026-01-05T00:00:00Z')], + }); + expect(derive(p, undefined, NOW).signedOffAt).toBe(Date.parse('2026-01-05T00:00:00Z') / 1000); + expect(derive(pull(), undefined, NOW).signedOffAt).toBeNull(); + }); + + it('names the failing CI checks', () => { + const p = withStatus({ + commit_statuses: [ci('failure', 'playwright'), ci('success', 'build')], + }); + expect(derive(p, undefined, NOW).ciFailing).toEqual(['playwright']); }); it('weight scales with size and sprawl', () => { diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index 3d55cec2..081cb829 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -3,18 +3,26 @@ import type { CommitStatus, PullData, RepoSpec, Signature } from '../types'; /** * One pull, one status. Mutually exclusive by precedence — the fix for v1's * six overlapping column predicates (a pull could sit in CR and QA at once). - * Precedence mirrors the what-to-review skill's derivation: - * draft > blocked > ci_red > needs_recr > needs_cr > needs_qa > - * ci_pending > ready + * + * draft > dev_block > ci_red > needs_recr > needs_cr > needs_qa > + * deploy_block > ci_pending > unmergeable > ready + * + * The old single "blocked" bucket conflated three opposite situations, so it + * split: dev_block means the author owes changes (it hides the pull from the + * review lanes — feedback is pending); deploy_block means the work is done + * but deliberately held from shipping (review proceeds as normal, so it only + * outranks the ready gate); unmergeable means signed off and green but + * conflicted or based on an unmerged parent (the author rebases). * * ci_pending exists only at the ready gate: pending CI never hides a pull * from the review lanes (reviews don't need green), but a fully-signed-off - * pull isn't "ready" until CI agrees. Merge conflicts and a dependent base - * gate the same way: reviewable as usual, but never "ready". + * pull isn't "ready" until CI agrees. */ export type Status = | 'draft' - | 'blocked' + | 'dev_block' + | 'deploy_block' + | 'unmergeable' | 'ci_red' | 'needs_recr' | 'needs_cr' @@ -28,7 +36,9 @@ export const STATUS_ORDER: Status[] = [ 'needs_qa', 'needs_cr', 'ci_pending', - 'blocked', + 'deploy_block', + 'unmergeable', + 'dev_block', 'ci_red', 'draft', ]; @@ -46,6 +56,8 @@ export interface DerivedPull { data: PullData; status: Status; ci: CiVerdict; + /** contexts of the failing required checks, so "CI red" can say which */ + ciFailing: string[]; /** users whose CR stamp counts on the current head */ crBy: string[]; qaBy: string[]; @@ -53,10 +65,16 @@ export interface DerivedPull { qaHave: number; /** users whose CR a later push invalidated and who haven't re-stamped */ recrBy: string[]; + /** users whose QA stamp a later push invalidated (symmetric with recrBy) */ + reqaBy: string[]; /** epoch secs of the invalidating push (head CI start proxy); null if unknown */ headPushedAt: number | null; ageDays: number; - /** still waiting on its first CR after STARVE_DAYS */ + /** days since updated_at — the activity clock, vs ageDays' open clock */ + idleDays: number; + /** epoch secs the last required sign-off landed; null until fully signed off */ + signedOffAt: number | null; + /** CR-incomplete past STARVE_DAYS — including half-reviewed and stale-CR rot */ starved: boolean; starveScore: number; weight: Weight; @@ -68,8 +86,10 @@ export interface DerivedPull { dependent: boolean; /** additions/deletions absent from the wire: weight and size sorts are guesses */ sizeKnown: boolean; - /** everyone holding an active dev/deploy block, oldest first */ - blockedBy: string[]; + /** everyone holding an active dev block: the author owes them changes */ + devBlockedBy: string[]; + /** everyone holding an active deploy block: done, deliberately not shipped */ + deployBlockedBy: string[]; /** login from the QAing label: someone is already testing this */ qaingBy: string | null; externalBlock: boolean; @@ -84,35 +104,48 @@ export const STARVE_DAYS = 7; const activeUsers = (sigs: Signature[]) => unique(sigs.filter(s => s.data.active).map(s => s.data.user.login)); +/** users with only invalidated stamps (no active one), excluding the author */ +const staleUsers = (sigs: Signature[], author: string) => { + const active = activeUsers(sigs); + return unique( + sigs + .filter(s => !s.data.active) + .map(s => s.data.user.login) + .filter(u => !active.includes(u) && u !== author) + ); +}; + function unique(xs: T[]): T[] { return [...new Set(xs)]; } /** - * CI verdict, computed client-side (the server ships raw statuses plus the - * per-repo required/ignored lists and leaves the reduction to the frontend). + * The statuses a repo's verdict depends on (the server ships raw statuses + * plus per-repo required/ignored lists and leaves the reduction to us). * Required = repoSpec.requiredStatuses when configured, else every reported * status minus repoSpec.ignoredStatuses. */ +function requiredStatuses(pull: PullData, spec?: RepoSpec): CommitStatus[] { + const onHead = pull.status.commit_statuses.filter(s => s.data.sha === pull.head.sha); + const statuses = onHead.length ? onHead : pull.status.commit_statuses; + if (spec?.requiredStatuses?.length) { + return statuses.filter(s => spec.requiredStatuses!.includes(s.data.context)); + } + const ignored = new Set(spec?.ignoredStatuses ?? []); + return statuses.filter(s => !ignored.has(s.data.context)); +} + +/** CI verdict, computed client-side. */ export function ciVerdict(pull: PullData, spec?: RepoSpec): CiVerdict { const onHead = pull.status.commit_statuses.filter(s => s.data.sha === pull.head.sha); const statuses = onHead.length ? onHead : pull.status.commit_statuses; if (!statuses.length && !spec?.requiredStatuses?.length) return 'none'; - let required: CommitStatus[]; - if (spec?.requiredStatuses?.length) { - required = statuses.filter(s => spec.requiredStatuses!.includes(s.data.context)); + const required = requiredStatuses(pull, spec); + if (spec?.requiredStatuses?.length && required.length < spec.requiredStatuses.length) { // a configured-required status that hasn't reported yet counts as pending - if (required.length < spec.requiredStatuses.length) { - const reported = new Set(required.map(s => s.data.context)); - if (spec.requiredStatuses.some(c => !reported.has(c))) { - if (required.some(s => isFailing(s))) return 'failing'; - return 'pending'; - } - } - } else { - const ignored = new Set(spec?.ignoredStatuses ?? []); - required = statuses.filter(s => !ignored.has(s.data.context)); + if (required.some(isFailing)) return 'failing'; + return 'pending'; } if (!required.length) return 'none'; if (required.some(isFailing)) return 'failing'; @@ -120,6 +153,15 @@ export function ciVerdict(pull: PullData, spec?: RepoSpec): CiVerdict { return 'success'; } +/** Which required checks are red — so the UI can say "CI red: playwright". */ +export function ciFailing(pull: PullData, spec?: RepoSpec): string[] { + return unique( + requiredStatuses(pull, spec) + .filter(isFailing) + .map(s => s.data.context) + ); +} + const isFailing = (s: CommitStatus) => s.data.state === 'failure' || s.data.state === 'error'; /** When did the author push the current head? Head-CI-start is the proxy. */ @@ -131,6 +173,9 @@ export function headPushedAt(pull: PullData): number | null { return starts.length ? Math.min(...starts) : null; } +export const crDone = (p: { crHave: number; data: PullData }) => p.crHave >= p.data.status.cr_req; +export const qaDone = (p: { qaHave: number; data: PullData }) => p.qaHave >= p.data.status.qa_req; + export function derive( pull: PullData, spec: RepoSpec | undefined, @@ -143,19 +188,14 @@ export function derive( const qaHave = qaBy.length; const ci = ciVerdict(pull, spec); - const staleCr = unique( - st.allCR - .filter(s => !s.data.active) - .map(s => s.data.user.login) - .filter(u => !crBy.includes(u) && u !== pull.user.login) - ); + const staleCr = staleUsers(st.allCR, pull.user.login); + const staleQa = staleUsers(st.allQA, pull.user.login); // a lifted block deactivates its signature, same as a stale CR stamp - const blockHolders = unique( - [...st.dev_block, ...st.deploy_block].filter(s => s.data.active).map(s => s.data.user.login) - ); - const crDone = crHave >= st.cr_req; - const qaDone = qaHave >= st.qa_req; + const devBlockedBy = activeUsers(st.dev_block); + const deployBlockedBy = activeUsers(st.deploy_block); + const crMet = crHave >= st.cr_req; + const qaMet = qaHave >= st.qa_req; const conflict = pull.mergeable === false; const dependent = !['main', 'master'].includes(pull.base.ref); @@ -163,33 +203,53 @@ export function derive( let status: Status; if (pull.draft) status = 'draft'; - else if (blockHolders.length) status = 'blocked'; + else if (devBlockedBy.length) status = 'dev_block'; else if (ci === 'failing') status = 'ci_red'; - else if (!crDone && staleCr.length) status = 'needs_recr'; - else if (!crDone) status = 'needs_cr'; - else if (!qaDone) status = 'needs_qa'; + else if (!crMet && staleCr.length) status = 'needs_recr'; + else if (!crMet) status = 'needs_cr'; + else if (!qaMet) status = 'needs_qa'; + else if (deployBlockedBy.length) status = 'deploy_block'; else if (ci === 'pending') status = 'ci_pending'; - // signed off and green, but unmergeable as-is: that's a block, not ready - else if (conflict || dependent) status = 'blocked'; + // signed off and green, but unmergeable as-is: the author rebases + else if (conflict || dependent) status = 'unmergeable'; else status = 'ready'; const created = Date.parse(pull.created_at) / 1000; + const updated = Date.parse(pull.updated_at) / 1000; const ageDays = Math.max(0, Math.floor((now - created) / 86400)); + const idleDays = Math.max(0, Math.floor((now - updated) / 86400)); const sizeKnown = pull.additions != null || pull.deletions != null; const size = (pull.additions ?? 0) + (pull.deletions ?? 0); - const starved = status === 'needs_cr' && crHave === 0 && ageDays >= STARVE_DAYS; + // Rot is rot whether the pull has zero stamps, one of two, or a stale one + // waiting on a re-stamp — the old `crHave === 0` cliff hid half-reviewed + // pulls from the aging lane forever. + const starved = ['needs_cr', 'needs_recr'].includes(status) && !crMet && ageDays >= STARVE_DAYS; + + const signedOffAt = + crMet && qaMet + ? Math.max( + 0, + ...[...st.allCR, ...st.allQA] + .filter(s => s.data.active) + .map(s => Date.parse(s.data.created_at) / 1000) + ) || null + : null; return { data: pull, status, ci, + ciFailing: ci === 'failing' ? ciFailing(pull, spec) : [], crBy, qaBy, crHave, qaHave, recrBy: staleCr, + reqaBy: staleQa, headPushedAt: headPushedAt(pull), ageDays, + idleDays, + signedOffAt, starved, starveScore: starved ? ageDays * Math.max(size, 1) : 0, weight: reviewWeight(pull), @@ -197,7 +257,8 @@ export function derive( mergeUnknown: pull.mergeable == null, dependent, sizeKnown, - blockedBy: blockHolders, + devBlockedBy, + deployBlockedBy, qaingBy: label(LABELS.qaing)?.user ?? null, externalBlock: !!label(LABELS.externalBlock), cryo: !!label(LABELS.cryo), diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index d97db16a..5694bd72 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -31,9 +31,11 @@ --badge-brand-fg: var(--brand-900); --badge-gray-bg: #e5e7eb; --badge-gray-fg: #101828; - --ok: #16a34a; - --warn: #d97706; - --bad: #dc2626; + /* text-grade in light mode: the 12px ledger ✓ and ⊘ are the two most + decision-critical glyphs on the board and must clear 4.5:1 on white */ + --ok: #15803d; + --warn: #b45309; + --bad: #b91c1c; --violet: #9333ea; --ease-quint: cubic-bezier(0.22, 1, 0.36, 1); --ease-quart: cubic-bezier(0.25, 1, 0.5, 1); @@ -121,6 +123,8 @@ a:hover { .badge-qa { background: var(--badge-purple-bg); color: var(--badge-purple-fg); } .badge-cr { background: var(--badge-gray-bg); color: var(--badge-gray-fg); } .badge-blocked { background: var(--badge-amber-bg); color: var(--badge-amber-fg); } + /* deploy hold: deliberate, not alarming — gray, not amber */ + .badge-hold { background: var(--badge-gray-bg); color: var(--badge-gray-fg); } .badge-red { background: var(--badge-red-bg); color: var(--badge-red-fg); } .badge-draft { background: none; color: var(--ink-3); border: 1px dashed var(--border); } @@ -174,10 +178,6 @@ a:hover { .settle-once > *:nth-child(4) { animation-delay: 120ms; } .settle-once > *:nth-child(n + 5) { animation-delay: 160ms; } -details[open] > .fold-body { - animation: settle 220ms var(--ease-quint); -} - @keyframes pop { from { opacity: 0; diff --git a/frontend-v2/src/views/Board.tsx b/frontend-v2/src/views/Board.tsx deleted file mode 100644 index 8ab1fb4d..00000000 --- a/frontend-v2/src/views/Board.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { pullKey } from '../format'; -import type { DerivedPull, Status } from '../model/status'; -import { crSort } from '../model/sort'; -import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; -import { BoardColumn } from '../components/Column'; -import { Fold, RestGroup } from '../components/Lane'; -import { Row, type RowOptions } from '../components/Row'; - -/** - * The classic v1 spatial model: every state as a column, scanned side by - * side. One difference on purpose: a pull appears in exactly one column - * (its highest-precedence status), so the counts are real and sum to the - * total — v1's overlapping predicates showed the same pull in CR and QA. - */ - -const COLUMNS: { status: Status; hint: string }[] = [ - { status: 'ready', hint: 'authors can merge' }, - { status: 'needs_recr', hint: 'reviewed, fix pushed' }, - { status: 'needs_cr', hint: 'the review pool' }, - { status: 'needs_qa', hint: 'CR done' }, - { status: 'ci_pending', hint: 'signed off, CI running' }, - { status: 'ci_red', hint: "usually the author's fix" }, - { status: 'blocked', hint: 'dev/deploy blocked, or signed off but unmergeable' }, - { status: 'draft', hint: 'not ready for review' }, -]; - -function Column({ - status, - hint, - pulls, - opts, -}: { - status: Status; - hint: string; - pulls: DerivedPull[]; - opts: RowOptions; -}) { - if (!pulls.length) return null; - const ordered = ['needs_cr', 'needs_recr'].includes(status) ? crSort(pulls) : pulls; - return ( - - - {STATUS_LABEL[status]} - {hint} - - } - > - {ordered.map(p => ( - - ))} - - ); -} - -export function Board({ - pulls, - bots, - opts, -}: { - pulls: DerivedPull[]; - bots: DerivedPull[]; - opts: RowOptions; -}) { - if (!pulls.length && !bots.length) { - return ; - } - const byStatus = new Map(); - for (const p of pulls) byStatus.set(p.status, [...(byStatus.get(p.status) ?? []), p]); - return ( - <> -
- {COLUMNS.map(({ status, hint }) => ( - - ))} -
- {bots.length > 0 && ( - - - {bots.map(p => ( - - ))} - - - )} - - ); -} diff --git a/frontend-v2/src/views/Classic.tsx b/frontend-v2/src/views/Classic.tsx index 6fb765c0..2f5ab273 100644 --- a/frontend-v2/src/views/Classic.tsx +++ b/frontend-v2/src/views/Classic.tsx @@ -1,9 +1,10 @@ -import type { DerivedPull } from '../model/status'; +import { crDone, qaDone, type DerivedPull } from '../model/status'; import type { PullData } from '../types'; import { ago, pullKey } from '../format'; import { EmptyState } from '../components/bits'; import { CardShell } from '../components/Card'; import { BoardColumn } from '../components/Column'; +import { Truncated } from '../components/Lane'; import { Row, type RowOptions } from '../components/Row'; /** @@ -23,8 +24,6 @@ const isDraft = (p: DerivedPull) => p.data.draft; // v1 hasPassedCI(): every required status successful; no statuses and no // required list counts as passed. ciVerdict encodes exactly that split. const passedCI = (p: DerivedPull) => p.ci === 'success' || p.ci === 'none'; -const crDone = (p: DerivedPull) => p.crHave >= p.data.status.cr_req; -const qaDone = (p: DerivedPull) => p.qaHave >= p.data.status.qa_req; const metDeployReqs = (p: DerivedPull) => crDone(p) && qaDone(p) && passedCI(p); const hasOutdatedSig = (p: DerivedPull, me: string) => @@ -65,9 +64,12 @@ function Column({ }) { return ( - {pulls.map(p => ( - - ))} + {/* a 60-row CR column is a 3600px scroll: cap it, keep the count honest */} + + {pulls.map(p => ( + + ))} + ); } diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index 2bf74a61..367d2595 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -1,10 +1,10 @@ -import type { ReactNode } from 'react'; import type { DerivedPull } from '../model/status'; +import { authorMove } from '../model/actions'; import type { PullData } from '../types'; import { ago, pullKey } from '../format'; import { EmptyState } from '../components/bits'; -import { Fold, Lane, RestGroup, Truncated } from '../components/Lane'; -import { Row, type RowOptions } from '../components/Row'; +import { AnnotatedRow, Fold, Lane, RestGroup, Truncated } from '../components/Lane'; +import type { RowOptions } from '../components/Row'; import { ClosedRow } from '../components/ClosedRow'; /** @@ -13,78 +13,26 @@ import { ClosedRow } from '../components/ClosedRow'; * person" never share a section. */ -/** Your-move verb per state; null = waiting on someone else. */ -function yourMove(p: DerivedPull): string | null { - if (p.status === 'ready') return 'Merge it'; - if (p.status === 'ci_red') return 'Fix CI'; - if (p.conflict) return 'Resolve conflicts'; - if (p.status === 'needs_qa' && !p.qaingBy) return 'Find a QA-er'; - if (p.status === 'draft') return 'Finish the draft'; - return null; -} - function waitingOn(p: DerivedPull): string { - if (p.status === 'blocked' && p.blockedBy.length) - return `blocked by ${p.blockedBy.join(', ')}, ask them to lift the block`; + if (p.status === 'deploy_block' && p.deployBlockedBy.length) + return `held from deploy by ${p.deployBlockedBy.join(', ')}, ask before shipping`; if (p.status === 'needs_recr' && p.recrBy.length) { const wait = p.headPushedAt ? ` (fix pushed ${ago(p.headPushedAt)} ago)` : ''; return `waiting on ${p.recrBy.join(', ')}’s re-stamp${wait}`; } - if (p.status === 'needs_cr') - return p.crHave > 0 - ? `${p.crHave} of ${p.data.status.cr_req} CRs, open ${p.ageDays}d` + if (p.status === 'needs_cr') { + if (p.crHave > 0) return `${p.crHave} of ${p.data.status.cr_req} CRs, open ${p.ageDays}d`; + return p.starved + ? `no CR for ${p.ageDays}d — go ask in channel` : `no CR yet, open ${p.ageDays}d`; + } if (p.status === 'needs_qa' && p.qaingBy) return `${p.qaingBy} is QAing now`; + if (p.status === 'needs_qa' && p.reqaBy.length) + return `waiting on ${p.reqaBy.join(', ')}’s re-QA`; if (p.status === 'ci_pending') return 'waiting on CI'; return 'waiting'; } -/** - * A row with the lane's explanation column beside it: the action label on - * the left in "Your move", the who-to-nudge note on the right in "Waiting - * on others". The row itself drops its cue — the annotation carries it. - */ -function AnnotatedRow({ - pull, - opts, - side, - children, -}: { - pull: DerivedPull; - opts: RowOptions; - side: 'left' | 'right'; - children: ReactNode; -}) { - const row = ( - - - - ); - return ( -
- {side === 'left' ? ( - <> - - {children} - - {row} - - ) : ( - <> - {row} - - {children} - - - )} -
- ); -} - export function MyWork({ pulls, closed, @@ -97,8 +45,8 @@ export function MyWork({ const me = opts.me; const mine = pulls.filter(p => p.data.user.login === me); const byUrgency = (a: DerivedPull, b: DerivedPull) => b.ageDays - a.ageDays; - const move = mine.filter(p => yourMove(p) !== null).sort(byUrgency); - const waiting = mine.filter(p => yourMove(p) === null).sort(byUrgency); + const move = mine.filter(p => authorMove(p) !== null).sort(byUrgency); + const waiting = mine.filter(p => authorMove(p) === null).sort(byUrgency); const shipped = closed.filter(p => p.user.login === me); if (!mine.length && !shipped.length) { @@ -121,7 +69,7 @@ export function MyWork({ > {move.map(p => ( - {yourMove(p)} + {authorMove(p)} ))} {!move.length && ( diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx index cf5163d9..0511cc95 100644 --- a/frontend-v2/src/views/People.tsx +++ b/frontend-v2/src/views/People.tsx @@ -128,6 +128,19 @@ export function People({ {counts.get(login) ?? 0} + {/* the lead roll-up: who owes re-stamps, without clicking + through 30 people */} + {(owes.get(login)?.length ?? 0) > 0 && ( + + ⊘{owes.get(login)!.length} + + )} , login === selectedPerson, () => onPerson(login) diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 61d3d67a..75427036 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -1,15 +1,19 @@ import type { DerivedPull, Status } from '../model/status'; import { pullKey } from '../format'; import { crSort } from '../model/sort'; +import { authorMove, reviewerMove } from '../model/actions'; import type { PullData } from '../types'; import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; -import { Fold, FoldRows, Lane, RestGroup, Truncated } from '../components/Lane'; +import { AnnotatedRow, Fold, FoldRows, Lane, RestGroup, Truncated } from '../components/Lane'; import type { RowOptions } from '../components/Row'; import { ClosedRow } from '../components/ClosedRow'; /** - * The reviewer's tab: other people's work only. Your own PRs live in - * My work — the two jobs never share a lane. + * The home tab. It opens with the one lane the whole app used to lack: every + * action that is yours — re-stamps you owe, your own merge buttons, your CI + * fixes — regardless of who authored the pull. The daily loop must not + * require flipping between Review and My work. Below that, other people's + * work to pick from. */ export function Review({ pulls, @@ -25,15 +29,32 @@ export function Review({ const me = opts.me; const others = pulls.filter(p => p.data.user.login !== me); - // 1. Act now: ONLY re-stamps you personally owe. The review pass found the - // old version padded with other people's jobs (their re-stamps, other - // authors' merge buttons) — three mornings of that and the lane reads - // as noise. Now it's always truthfully "minutes, and yours". - const actNow = others.filter(p => p.status === 'needs_recr' && p.recrBy.includes(me)); + // 1. Yours to do: strictly your verbs. The earlier "Act now" lesson still + // binds — padding this with other people's jobs made it noise — but + // your own merge button is your job, whichever tab you're on. + const MOVE_RANK = [ + 'Re-stamp', + 'Re-QA', + 'Finish QA', + 'Merge it', + 'Fix CI', + 'Address feedback', + 'Rebase', + ]; + const todo = pulls + .map(p => ({ + p, + verb: p.data.user.login === me ? ownVerb(p) : reviewerMove(p, me), + })) + .filter((x): x is { p: DerivedPull; verb: string } => x.verb !== null) + .sort( + (a, b) => + MOVE_RANK.indexOf(a.verb) - MOVE_RANK.indexOf(b.verb) || b.p.ageDays - a.p.ageDays + ); - // 2. Review queue: lightest first. Includes pulls waiting on someone - // else's re-stamp — a fresh CR from you counts there too (the stale - // pip marks them). + // 2. Review queue: best next review first (leverage + age + weight). + // Includes pulls waiting on someone else's re-stamp — a fresh CR from + // you counts there too (the stale pip marks them). const crPool = others.filter( p => (p.status === 'needs_cr' && !p.crBy.includes(me)) || @@ -46,21 +67,40 @@ export function Review({ // in the rest group, not a lane at the top. const ready = others.filter(p => p.status === 'ready'); - // 4. Needs QA: a real lane again (v1's QA column earned it); QAing-label - // rows sort last since someone is already on them. - const needsQa = [...others.filter(p => p.status === 'needs_qa')].sort( - (a, b) => Number(!!a.qaingBy) - Number(!!b.qaingBy) - ); + // 4. Needs QA is a query, not the status bucket: QA runs in parallel with + // CR here (v1's QA column predicate), so anything QA-incomplete with + // green CI belongs — not just pulls whose CR is already done. Your own + // in-flight QA is pinned first (v1 qaCompare), unclaimed next, claimed + // by someone else last. + const needsQa = others + .filter( + p => + p.qaHave < p.data.status.qa_req && + ['success', 'none'].includes(p.ci) && + !p.conflict && + !['draft', 'dev_block'].includes(p.status) && + // your in-flight QA and owed re-QAs live in "Yours to do" + p.qaingBy !== me && + !(p.status === 'needs_qa' && p.reqaBy.includes(me)) + ) + .sort( + (a, b) => + Number(b.qaingBy === me) - Number(a.qaingBy === me) || + Number(!!a.qaingBy && a.qaingBy !== me) - Number(!!b.qaingBy && b.qaingBy !== me) || + b.ageDays - a.ageDays + ); const stamped = others.filter(p => p.status === 'needs_cr' && p.crBy.includes(me)); const byStatus = (s: Status) => others.filter(p => p.status === s); - const blocked = byStatus('blocked'); + const devBlocked = byStatus('dev_block'); + const deployHeld = byStatus('deploy_block'); + const unmergeable = byStatus('unmergeable'); const ciPending = byStatus('ci_pending'); const ciRed = byStatus('ci_red'); const drafts = byStatus('draft'); // bots/shipped stay reachable even when no human PRs need review - const empty = !others.length && !bots.length && !closed.length; + const empty = !pulls.length && !bots.length && !closed.length; if (empty) { return ( - + {todo.length > 0 && ( + + + {todo.map(({ p, verb }) => ( + + {verb} + + ))} + + + )} - + - - + + + + + + + + - + ); } + +// Your own pulls contribute only their do-it-now verbs to the home lane; +// "Find a QA-er" and "Finish the draft" stay in My work — they're planning, +// not minutes. +function ownVerb(p: DerivedPull): string | null { + const verb = authorMove(p); + return verb && ['Merge it', 'Fix CI', 'Address feedback', 'Rebase'].includes(verb) ? verb : null; +} From 560ee58b12a6f8e3f7b2a8f0a1552275b89bcc62 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 00:33:57 -0700 Subject: [PATCH 038/381] Freshness that answers what changed, and survives a glance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changed-since-last-look system had four holes the critique pass called out: - A two-second tab switch to Slack stamped lastSeen, marking a weekend's forty changes as seen. The stamp now requires 45 cumulative visible seconds — a glance can't erase the delta. - New PRs and updated PRs wore the same dot. Solid = new, ring = updated; both named in the Legend and exposed to screen readers. - There was no way to acknowledge a single row: the whole board cleared at once, on departure. Opening a PR (the natural 'seen' moment) now clears its dot for the session, and the banner count drops with it. - Merges while you were away were invisible outside a fold. The banner now counts them ('3 PRs changed · 2 merged or closed since your last look') and recently-shipped rows carry the dot. Also: the changed-only toggle and the banner share one predicate (bots excluded from both — they previously disagreed), the fresh background flash fires once per pull per session instead of replaying on every lens switch, and the fold-open animation is gone (220ms of slide on the fiftieth toggle of the day is pure tax). Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 55 ++++++++++++++++-------- frontend-v2/src/components/Card.tsx | 15 +++++-- frontend-v2/src/components/ClosedRow.tsx | 13 +++++- frontend-v2/src/components/Legend.tsx | 8 +++- frontend-v2/src/components/Row.tsx | 52 +++++++++++++++++----- frontend-v2/src/components/bits.tsx | 5 +++ frontend-v2/src/store.ts | 42 +++++++++++++++++- frontend-v2/src/styles.css | 11 ++++- frontend-v2/src/views/MyWork.tsx | 2 +- frontend-v2/src/views/Review.tsx | 2 +- 10 files changed, 165 insertions(+), 40 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index b8cc4d25..87026e2b 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -3,7 +3,7 @@ import { ago, n, shortRepo } from './format'; import { readStorage, writeStorage } from './storage'; import { STATUS_ORDER, type DerivedPull } from './model/status'; import type { Team } from './types'; -import { usePulldasher } from './store'; +import { isFresh, usePulldasher } from './store'; import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy'; import { loadSiteConfig, useScope } from './prefs'; import { Legend } from './components/Legend'; @@ -108,6 +108,7 @@ export function App() { authFailed, lastPayloadAt, lastSeen, + acked, } = usePulldasher(); const [scope] = useScope(); // a v1 bookmark (?repo=…&author=…&cryo=1…) opens Classic configured the @@ -233,18 +234,22 @@ export function App() { return out; }, [pulls, scope, query, showHidden, hiddenRepos, legacy, me, isBot]); + // changed-only and the banner count share one predicate (bots excluded, + // acked rows drop out) so the toggle always shows exactly what the banner + // promised const scoped = useMemo( () => - onlyChanged - ? inScope.filter(p => Date.parse(p.data.updated_at) / 1000 > lastSeen) - : inScope, - [inScope, onlyChanged, lastSeen] + onlyChanged ? inScope.filter(p => !isBot(p) && isFresh(p.data, lastSeen, acked)) : inScope, + [inScope, onlyChanged, lastSeen, acked, isBot] ); const humans = scoped.filter(p => !isBot(p)); const bots = scoped.filter(isBot); - const changedCount = inScope.filter( - p => !isBot(p) && Date.parse(p.data.updated_at) / 1000 > lastSeen + const changedCount = inScope.filter(p => !isBot(p) && isFresh(p.data, lastSeen, acked)).length; + // "did my PR merge over the weekend" is the cheapest answer the board can + // give — it belongs in the banner, not buried in a fold + const mergedCount = closed.filter( + p => (Date.parse(p.closed_at ?? '') / 1000 || 0) > lastSeen ).length; const hiddenCount = pulls.filter( p => p.cryo || (hiddenRepos.has(p.data.repo) && !scope.repos.includes(p.data.repo)) @@ -261,8 +266,8 @@ export function App() { }, []); // stable identity so memo(Row) can skip untouched rows on socket bursts const rowOpts: RowOptions = useMemo( - () => ({ me, lastSeen, onPerson }), - [me, lastSeen, onPerson] + () => ({ me, lastSeen, acked, onPerson }), + [me, lastSeen, acked, onPerson] ); const mineCount = humans.filter(p => p.data.user.login === me).length; @@ -410,20 +415,32 @@ export function App() {
)} - {changedCount > 0 && !query && ( + {(changedCount > 0 || mergedCount > 0) && !query && ( - {n(changedCount, 'PR')} changed since your last - look + {changedCount > 0 && ( + <> + {n(changedCount, 'PR')} changed + + )} + {changedCount > 0 && mergedCount > 0 && ' · '} + {mergedCount > 0 && ( + <> + {mergedCount} merged or closed + + )}{' '} + since your last look - + {changedCount > 0 && ( + + )} )} diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index 43854821..9497f01c 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -14,6 +14,7 @@ export function CardShell({ number, title, fresh, + onOpen, className = '', right, }: { @@ -22,7 +23,9 @@ export function CardShell({ repo: string; number: number; title: string; - fresh?: boolean; + /** 'new' = opened since your last look (solid dot); 'updated' = changed (ring) */ + fresh?: 'new' | 'updated' | null; + onOpen?: () => void; className?: string; right: ReactNode; }) { @@ -33,8 +36,12 @@ export function CardShell({ {/* lives in the padding gutter: a changed card must not indent its content */} {fresh && ( )} @@ -42,7 +49,7 @@ export function CardShell({ - + diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index 48dcc856..74b5491b 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -8,11 +8,20 @@ import { Avatar, PullTitleLink, RepoRef } from './bits'; * line. Columns must use a compact two-zone card instead (Classic's * ClosedCard). */ -export function ClosedRow({ pull }: { pull: PullData }) { +export function ClosedRow({ pull, lastSeen }: { pull: PullData; lastSeen?: number }) { const merged = !!pull.merged_at; const closedAt = Date.parse(pull.closed_at ?? pull.updated_at) / 1000; + const fresh = lastSeen != null && closedAt > lastSeen; return ( -
+
+ {fresh && ( + + )} Can't merge, 'signed off but conflicted or on an unmerged parent: author rebases' )} - {item(, 'changed since your last look')} + {item( + + + + , + 'since your last look: solid = new PR, ring = updated. Opening one clears it' + )} {item(, 'Cryogenic Storage PRs and quiet repos, hidden by default')} )} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index ee1339c7..b3994e18 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -1,8 +1,8 @@ import { memo, useState } from 'react'; import type { DerivedPull } from '../model/status'; import { isIterating } from '../model/status'; -import { ago } from '../format'; -import { refreshPull } from '../store'; +import { ago, pullKey } from '../format'; +import { ackPull, isFresh, refreshPull } from '../store'; import { AgeStamp, Avatar, PullTitleLink, RepoRef, SigPips, StatusBadge, WeightChip } from './bits'; import { CardShell } from './Card'; @@ -22,9 +22,30 @@ export interface RowOptions { cue?: boolean; me: string; lastSeen: number; + /** pull keys opened this session (their fresh dots are cleared) */ + acked: ReadonlySet; onPerson?: (login: string) => void; } +/** + * A fresh row is a brand-new PR (solid dot) or an updated one (ring), and + * opening it clears the mark for the session. + */ +function freshKind(p: DerivedPull, opts: RowOptions): 'new' | 'updated' | null { + if (!isFresh(p.data, opts.lastSeen, opts.acked)) return null; + return Date.parse(p.data.created_at) / 1000 > opts.lastSeen ? 'new' : 'updated'; +} + +// The background flash runs once per pull per session, not on every lens +// switch that remounts the row. Recording during render is deliberate: memo +// keeps re-renders rare, and a double-record is harmless. +const flashed = new Set(); +function flashOnce(key: string, fresh: boolean): boolean { + if (!fresh || flashed.has(key)) return false; + flashed.add(key); + return true; +} + function cue(p: DerivedPull, me: string): string | null { const d = p.data; if (p.status === 'needs_recr' && p.recrBy.length) { @@ -189,7 +210,8 @@ function Ledger({ pull, me }: { pull: DerivedPull; me: string }) { */ function CompactCard({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const d = pull.data; - const fresh = Date.parse(d.updated_at) / 1000 > opts.lastSeen; + const key = pullKey(d); + const fresh = freshKind(pull, opts); return ( ackPull(key)} + className={`pd-row ${flashOnce(key, !!fresh) ? 'row-fresh' : ''} transition-[background-color] duration-150 motion-reduce:transition-none`} right={ <> @@ -212,30 +235,38 @@ function CompactCard({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const d = pull.data; const compact = opts.compact === true; - const fresh = Date.parse(d.updated_at) / 1000 > opts.lastSeen; if (compact) return ; + const key = pullKey(d); + const fresh = freshKind(pull, opts); const showWeight = pull.sizeKnown && ['needs_cr', 'needs_recr'].includes(pull.status); const line = opts.cue === false ? null : cue(pull, opts.me); return (
{/* lives in the padding gutter: a changed row must not indent its content */} {fresh && ( )} {opts.badge === false ? null : } {/* the title is how a reviewer decides: never truncate it, wrap instead */} - + ackPull(key)} + /> {isIterating(d) && ( @@ -284,5 +315,6 @@ export const Row = memo( a.opts.cue === b.opts.cue && a.opts.me === b.opts.me && a.opts.lastSeen === b.opts.lastSeen && + a.opts.acked === b.opts.acked && a.opts.onPerson === b.opts.onPerson ); diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index bb10679f..c6e6f9dd 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -302,10 +302,13 @@ export function PullTitleLink({ repo, number, title, + onOpen, }: { repo: string; number: number; title: string; + /** fired when the user opens the PR — the row's natural "seen" ack */ + onOpen?: () => void; }) { return ( {title} diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts index 47c142eb..1d08d0ed 100644 --- a/frontend-v2/src/store.ts +++ b/frontend-v2/src/store.ts @@ -25,6 +25,8 @@ export interface Snapshot { lastPayloadAt: number; /** epoch secs of the last-seen marker (previous visit's departure) */ lastSeen: number; + /** pull keys opened this session: their fresh dots are cleared */ + acked: ReadonlySet; } const LAST_SEEN_KEY = 'pd2.lastSeen'; @@ -40,13 +42,49 @@ const listeners = new Set<() => void>(); // The marker advances when you LEAVE (pagehide / tab hidden), not when you // arrive — an accidental reload must not erase "changed since yesterday". +// And only after the page has actually been LOOKED AT: a two-second Monday +// glance on the way to Slack must not mark the weekend's forty changes as +// seen. "Looked at" = 45 cumulative visible seconds since the last stamp. +const MIN_ATTENDED_SECS = 45; const lastSeen = Number(readStorage(LAST_SEEN_KEY)) || Date.now() / 1000 - 6 * 3600; -const stampSeen = () => writeStorage(LAST_SEEN_KEY, String(Date.now() / 1000)); +let attendedSecs = 0; +let visibleSince: number | null = document.visibilityState === 'visible' ? Date.now() / 1000 : null; +const settleAttention = () => { + if (visibleSince != null) { + attendedSecs += Date.now() / 1000 - visibleSince; + visibleSince = null; + } +}; +const stampSeen = () => { + settleAttention(); + if (attendedSecs < MIN_ATTENDED_SECS) return; + writeStorage(LAST_SEEN_KEY, String(Date.now() / 1000)); + attendedSecs = 0; +}; window.addEventListener('pagehide', stampSeen); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') stampSeen(); + else visibleSince = Date.now() / 1000; }); +// Per-row acknowledgment: opening a PR clears its fresh dot for this session +// without waiting for departure to stamp the whole board. +const acked = new Set(); +export function ackPull(key: string) { + if (acked.has(key)) return; + acked.add(key); + schedulePublish(); +} + +/** The one fresh predicate: changed since your last look and not yet opened. */ +export function isFresh( + d: Pick, + lastSeenAt: number, + ackedKeys: ReadonlySet +) { + return Date.parse(d.updated_at) / 1000 > lastSeenAt && !ackedKeys.has(`${d.repo}#${d.number}`); +} + let snapshot: Snapshot = { pulls: [], repoSpecs, @@ -57,6 +95,7 @@ let snapshot: Snapshot = { authFailed, lastPayloadAt, lastSeen, + acked: new Set(acked), }; // derive() is pure per (pull, spec): cache on reference identity so a @@ -88,6 +127,7 @@ function publish() { authFailed, lastPayloadAt, lastSeen, + acked: new Set(acked), }; for (const fn of listeners) fn(); } diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 5694bd72..75cb0d39 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -213,7 +213,8 @@ a:hover { animation: fresh-flash 1.4s var(--ease-quart) 300ms backwards; } -/* the "changed since your last look" marker at the row's entry point */ +/* the since-your-last-look markers at the row's entry point: + solid = a brand-new PR, ring = an existing PR that changed */ .dot-fresh { flex: none; width: 7px; @@ -221,6 +222,14 @@ a:hover { border-radius: 9999px; background: var(--brand); } +.dot-updated { + flex: none; + width: 7px; + height: 7px; + border-radius: 9999px; + border: 2px solid var(--brand); + background: transparent; +} /* press feedback: house scale-97 */ .pressable { diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index 367d2595..b9b3c325 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -106,7 +106,7 @@ export function MyWork({ > {shipped.map(p => ( - + ))} diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 75427036..29014215 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -231,7 +231,7 @@ export function Review({ > {closed.map(p => ( - + ))} From 2a53ed625efc9a5f417c50c00b887081c6a1f66a Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 00:37:52 -0700 Subject: [PATCH 039/381] Every board is a URL again, and the filter learns the obvious searches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 kept the whole view in the query string: any board was pasteable, a bookmark was a saved view, and the back button navigated. v2 had regressed all three to a localStorage blob and replaceState. Now the hash carries lens, person/team, query, scope, and toggles — defaults omitted so a plain /v2/ URL stays plain. Opening a teammate's link applies their scope for the session without overwriting your saved one (primeScope); view changes push history entries so back works, while query keystrokes replace in place. The filter box also stops failing the most common search: a bare number (or #number) matches the PR number, labels are matchable at last (label:qae — arbitrary labels were invisible everywhere), and status:/older:/repo:/author: tokens narrow one field. Unknown tokens fall back to plain substring so nothing breaks mid-typing. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 105 ++++++++++++++++++++-------- frontend-v2/src/model/query.test.ts | 59 ++++++++++++++++ frontend-v2/src/model/query.ts | 45 ++++++++++++ frontend-v2/src/prefs.ts | 11 +++ 4 files changed, 192 insertions(+), 28 deletions(-) create mode 100644 frontend-v2/src/model/query.test.ts create mode 100644 frontend-v2/src/model/query.ts diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 87026e2b..a3f8c029 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -5,7 +5,8 @@ import { STATUS_ORDER, type DerivedPull } from './model/status'; import type { Team } from './types'; import { isFresh, usePulldasher } from './store'; import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy'; -import { loadSiteConfig, useScope } from './prefs'; +import { loadSiteConfig, primeScope, useScope } from './prefs'; +import { matchesQuery } from './model/query'; import { Legend } from './components/Legend'; import { ScopeControl } from './components/Scope'; import { STATUS_LABEL } from './components/bits'; @@ -19,8 +20,24 @@ type Lens = 'review' | 'mine' | 'people' | 'classic'; const LENSES: Lens[] = ['review', 'mine', 'people', 'classic']; -/** Lens and drill-down selections live in the hash: shareable, bookmarkable. */ -function readHash() { +/** + * The whole view lives in the hash — lens, drill-downs, query, scope, + * toggles — so any board is pasteable and a bookmark is a saved view + * (v1's superpower, restored). Defaults are omitted, so a plain /v2/ URL + * stays plain. + */ +interface HashState { + lens: Lens; + person: string | null; + team: string | null; + q: string; + repos: string[]; + authors: string[]; + hidden: boolean; + changed: boolean; +} + +function readHash(): HashState { const p = new URLSearchParams(location.hash.slice(1)); let lens = p.get('lens') as Lens | null; // the Board lens merged into Classic (same columns, real justification); @@ -30,16 +47,32 @@ function readHash() { lens: lens && LENSES.includes(lens) ? lens : ('review' as Lens), person: p.get('person'), team: p.get('team'), + q: p.get('q') ?? '', + repos: p.get('repos')?.split(',').filter(Boolean) ?? [], + authors: p.get('authors')?.split(',').filter(Boolean) ?? [], + hidden: p.get('hidden') === '1', + changed: p.get('changed') === '1', }; } -function writeHash(lens: Lens, person: string | null, team: string | null) { +function buildHash(s: HashState): string { const p = new URLSearchParams(); - if (lens !== 'review') p.set('lens', lens); - if (person) p.set('person', person); - if (team) p.set('team', team); - const next = p.toString(); - history.replaceState(null, '', next ? `#${next}` : location.pathname + location.search); + if (s.lens !== 'review') p.set('lens', s.lens); + if (s.person) p.set('person', s.person); + if (s.team) p.set('team', s.team); + if (s.q) p.set('q', s.q); + if (s.repos.length) p.set('repos', s.repos.join(',')); + if (s.authors.length) p.set('authors', s.authors.join(',')); + if (s.hidden) p.set('hidden', '1'); + if (s.changed) p.set('changed', '1'); + return p.toString(); +} + +// A shared link's scope applies for the session without touching the +// visitor's saved scope; their own edits still persist as usual. +const urlState = readHash(); +if (urlState.repos.length || urlState.authors.length) { + primeScope({ repos: urlState.repos, authors: urlState.authors }); } const THEME_KEY = 'pd2.theme'; @@ -119,12 +152,12 @@ export function App() { if (legacy && !location.hash.includes('lens=')) return 'classic'; return h.lens; }); - const [person, setPerson] = useState(() => readHash().person); - const [team, setTeam] = useState(() => readHash().team); - const [query, setQuery] = useState(''); - const [onlyChanged, setOnlyChanged] = useState(false); + const [person, setPerson] = useState(() => urlState.person); + const [team, setTeam] = useState(() => urlState.team); + const [query, setQuery] = useState(() => urlState.q); + const [onlyChanged, setOnlyChanged] = useState(() => urlState.changed); const [showHidden, setShowHidden] = useState( - () => !!legacy && (legacy.cryo || legacy.showAllRepos) + () => urlState.hidden || (!!legacy && (legacy.cryo || legacy.showAllRepos)) ); const [teams, setTeams] = useState([]); const [extraBots, setExtraBots] = useState>(new Set()); @@ -150,15 +183,40 @@ export function App() { setTeams(c.teams); }); }, []); + // View changes (lens, person, team) earn a history entry so the back + // button navigates between boards; filter tweaks replace in place so + // typing a query doesn't bury history under keystrokes. + const prevView = useRef({ lens, person, team }); useEffect(() => { - writeHash(lens, person, team); - }, [lens, person, team]); + const next = buildHash({ + lens, + person, + team, + q: query, + repos: scope.repos, + authors: scope.authors, + hidden: showHidden, + changed: onlyChanged, + }); + if (next === location.hash.slice(1)) return; + const prev = prevView.current; + prevView.current = { lens, person, team }; + if (prev.lens !== lens || prev.person !== person || prev.team !== team) { + // fires hashchange; the listener below re-reads idempotently + location.hash = next; + } else { + history.replaceState(null, '', next ? `#${next}` : location.pathname + location.search); + } + }, [lens, person, team, query, scope, showHidden, onlyChanged]); useEffect(() => { const onHash = () => { const h = readHash(); setLens(h.lens); setPerson(h.person); setTeam(h.team); + setQuery(h.q); + setShowHidden(h.hidden); + setOnlyChanged(h.changed); }; window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); @@ -220,17 +278,7 @@ export function App() { // no matter whose work you follow (they land in the bots fold, not lanes) if (scope.authors.length) out = out.filter(p => isBot(p) || scope.authors.includes(p.data.user.login)); - if (query) { - const terms = query.toLowerCase().split(/\s+/).filter(Boolean); - out = out.filter(p => - terms.every( - t => - p.data.title.toLowerCase().includes(t) || - p.data.repo.toLowerCase().includes(t) || - p.data.user.login.toLowerCase().includes(t) - ) - ); - } + if (query) out = out.filter(p => matchesQuery(p, query)); return out; }, [pulls, scope, query, showHidden, hiddenRepos, legacy, me, isBot]); @@ -347,8 +395,9 @@ export function App() { setQuery(e.target.value)} className="h-8 w-[170px] rounded-lg border border-line bg-surface px-2.5 text-[13px]" diff --git a/frontend-v2/src/model/query.test.ts b/frontend-v2/src/model/query.test.ts new file mode 100644 index 00000000..821a179d --- /dev/null +++ b/frontend-v2/src/model/query.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import type { DerivedPull } from './status'; +import { matchesQuery } from './query'; + +function fake( + over: Partial<{ + title: string; + repo: string; + author: string; + number: number; + labels: string[]; + status: DerivedPull['status']; + ageDays: number; + }> +): DerivedPull { + return { + status: over.status ?? 'needs_cr', + ageDays: over.ageDays ?? 0, + data: { + title: over.title ?? 'Fix the store dropdown', + repo: over.repo ?? 'acme/widgets', + number: over.number ?? 12345, + user: { login: over.author ?? 'alice' }, + labels: (over.labels ?? []).map(title => ({ title })), + }, + } as unknown as DerivedPull; +} + +describe('matchesQuery', () => { + it('matches PR numbers, bare or #-prefixed', () => { + expect(matchesQuery(fake({ number: 63495 }), '63495')).toBe(true); + expect(matchesQuery(fake({ number: 63495 }), '#63495')).toBe(true); + expect(matchesQuery(fake({ number: 111 }), '63495')).toBe(false); + }); + + it('bare terms AND across title, repo, author, labels', () => { + const p = fake({ title: 'Store dropdown', author: 'alice', labels: ['QAE'] }); + expect(matchesQuery(p, 'store alice')).toBe(true); + expect(matchesQuery(p, 'qae')).toBe(true); + expect(matchesQuery(p, 'store bob')).toBe(false); + }); + + it('label:, status:, older:, repo:, author: tokens narrow one field', () => { + const p = fake({ labels: ['QAE'], status: 'needs_recr', ageDays: 9, author: 'alice' }); + expect(matchesQuery(p, 'label:qae')).toBe(true); + expect(matchesQuery(p, 'label:frontend')).toBe(false); + expect(matchesQuery(p, 'status:needs-recr')).toBe(true); + expect(matchesQuery(p, 'status:ready')).toBe(false); + expect(matchesQuery(p, 'older:7')).toBe(true); + expect(matchesQuery(p, 'older:7d')).toBe(true); + expect(matchesQuery(p, 'older:30')).toBe(false); + expect(matchesQuery(p, 'author:ali')).toBe(true); + expect(matchesQuery(p, 'repo:widgets')).toBe(true); + }); + + it('an unknown key falls back to plain substring', () => { + expect(matchesQuery(fake({ title: 'weird:token in title' }), 'weird:token')).toBe(true); + }); +}); diff --git a/frontend-v2/src/model/query.ts b/frontend-v2/src/model/query.ts new file mode 100644 index 00000000..e4d3308f --- /dev/null +++ b/frontend-v2/src/model/query.ts @@ -0,0 +1,45 @@ +import type { DerivedPull } from './status'; + +/** + * The filter box grammar. Bare terms AND-match as substrings across title, + * repo, author, and labels; a bare number (or #number) matches the PR + * number — the single most common search. Tokens narrow one field: + * + * label:qae any label contains "qae" + * status:ready the derived status (spaces or dashes work: status:needs-cr) + * older:5 open at least 5 days (older:5d also accepted) + * repo:ifixit repo name contains + * author:al author login contains + */ +export function matchesQuery(p: DerivedPull, query: string): boolean { + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + return terms.every(t => matchTerm(p, t)); +} + +function matchTerm(p: DerivedPull, term: string): boolean { + const d = p.data; + const i = term.indexOf(':'); + if (i > 0) { + const key = term.slice(0, i); + const val = term.slice(i + 1); + if (val) { + if (key === 'label') return d.labels.some(l => l.title.toLowerCase().includes(val)); + if (key === 'status') return p.status.includes(val.replace(/[\s-]+/g, '_')); + if (key === 'older') { + const days = Number.parseInt(val, 10); + return Number.isFinite(days) && p.ageDays >= days; + } + if (key === 'repo') return d.repo.toLowerCase().includes(val); + if (key === 'author') return d.user.login.toLowerCase().includes(val); + // unknown key: treat the whole term as a plain substring below + } + } + const bare = term.startsWith('#') ? term.slice(1) : term; + if (/^\d+$/.test(bare)) return String(d.number).includes(bare); + return ( + d.title.toLowerCase().includes(term) || + d.repo.toLowerCase().includes(term) || + d.user.login.toLowerCase().includes(term) || + d.labels.some(l => l.title.toLowerCase().includes(term)) + ); +} diff --git a/frontend-v2/src/prefs.ts b/frontend-v2/src/prefs.ts index 61314419..3a4fd7fd 100644 --- a/frontend-v2/src/prefs.ts +++ b/frontend-v2/src/prefs.ts @@ -30,6 +30,17 @@ function loadScope(): Scope { let scope = loadScope(); const listeners = new Set<() => void>(); +/** + * Apply a scope from a shared URL for this session WITHOUT persisting it — + * opening a teammate's link must not silently overwrite your saved board. + * The moment the user edits the scope themselves, useScope's setter saves + * as usual. + */ +export function primeScope(next: Scope) { + scope = next; + for (const fn of listeners) fn(); +} + export function useScope(): [Scope, (next: Scope) => void] { const value = useSyncExternalStore( fn => { From a7867e3c0690427627eab87c9193509c65fbff7d Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 00:45:24 -0700 Subject: [PATCH 040/381] Keyboard model, popover focus discipline, and the README catches up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit j/k walk the rows (Enter opens — the focused element is the title link), c copies the focused row's branch, / still jumps to the filter; all documented in the Legend. Popovers now share one hook (usePopover: click-away, Escape-with-focus-return, and focus moving INTO the panel on open — the aria-haspopup promise the old copies didn't keep). Warn flags and fresh dots gained aria-labels; closed rows joined the j/k ring. Co-Authored-By: Claude Fable 5 --- frontend-v2/README.md | 52 ++++++++++++++++-------- frontend-v2/src/app.tsx | 39 +++++++++++++++--- frontend-v2/src/components/ClosedRow.tsx | 2 +- frontend-v2/src/components/Legend.tsx | 4 ++ frontend-v2/src/components/Row.tsx | 2 +- 5 files changed, 75 insertions(+), 24 deletions(-) diff --git a/frontend-v2/README.md b/frontend-v2/README.md index 60620589..adaa0c2b 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -8,23 +8,42 @@ serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol ## Ideas - **One pull, one status.** `src/model/status.ts` derives a single mutually - exclusive status per pull (draft > blocked > ci_red > needs_recr > - needs_cr > needs_qa > ready) instead of v1's overlapping column predicates. -- **The re-stamp lane.** Inactive CR signatures (invalidated by a push) are - already on the wire; a pull that was reviewed and fixed but lacks a fresh - stamp is the cheapest review on the board, so it leads. -- **Five lenses, one filter.** Review / My work / People (person or team - drill-down) / Board / Classic, over the same pool, with a single saved - Scope (repos + people, with GitHub-team presets). Lens and drill-down live - in the URL hash, so views are shareable. + exclusive status per pull (draft > dev_block > ci_red > needs_recr > + needs_cr > needs_qa > deploy_block > ci_pending > unmergeable > ready) + instead of v1's overlapping column predicates. The three flavors of + "blocked" are separate statuses because they mean opposite things: a dev + block is the author's move, a deploy hold is done-but-don't-ship, and + unmergeable is a rebase. +- **Yours to do.** The Review tab opens with every action that's yours — + re-stamps you owe (CR and QA), your merge buttons, your CI fixes — + regardless of who authored the pull. `src/model/actions.ts` is the single + source for "whose move is it", shared with My work. +- **The re-stamp lane.** Inactive CR/QA signatures (invalidated by a push) + are already on the wire; a pull that was reviewed and fixed but lacks a + fresh stamp is the cheapest review on the board, so it leads. The ledger + shows partial staleness (`1/2⊘`), marks slots you stamped with a dotted + underline, and clicking a slot lists who signed and when. +- **A queue that ranks the right thing.** `src/model/sort.ts` scores by + weight, then boosts pulls one stamp from done and credits age, so an old M + outranks a fresh S before the starvation cliff. Ages heat up (amber past a + week, red past two) with both clocks in the tooltip. +- **Four lenses, one filter.** Review / My work / People (person or team + drill-down) / Classic, over the same pool, with a single saved Scope + (repos + people, with GitHub-team presets). The whole view — lens, query, + scope, toggles — lives in the URL hash: any board is pasteable and a + bookmark is a saved view. The filter understands `#number`, `label:x`, + `status:x`, `older:5`, `repo:x`, and `author:x`. - **Classic is v1, faithfully.** The same six overlapping columns (CI Blocked / Deploy Blocked / Ready / Dev Block / CR / QA), predicates and sorts ported line-for-line from v1, for anyone whose muscle memory - lives there. Board is the v2 take on the same idea (one column per pull, - mutually exclusive statuses). + lives there. - **Changed since your last look.** A last-seen marker in localStorage, - stamped when you leave (pagehide), not when you arrive; rows updated after - it get a dot, and a banner offers "show only changes". + stamped when you leave (pagehide) and only after ~45 visible seconds, so a + glance at another tab can't erase the weekend's delta. Solid dot = new PR, + ring = updated; opening a PR clears its dot; the banner also counts merges + while you were away. +- **Keyboard:** `/` filter, `j`/`k` walk rows, `Enter` opens, `c` copies the + focused row's branch. ## Running it @@ -34,7 +53,7 @@ npm install npm run dev # against a local backend on :3000 (proxies /token + socket) npm run dev:dummy # no backend: v1's dummy-pulls.json fixture npm run build # type-check + bundle to frontend-v2/dist (served at /v2) -npm test # vitest: status-derivation and CI-verdict unit tests +npm test # vitest: status, sort, query, and legacy-URL unit tests ``` ## Site config @@ -57,7 +76,8 @@ them. A URL with any v1 param (`repo`, `author`, `cryo`, `drafts`, `cr`/`qa` column-collapse flags) opens the Classic lens configured the same way, v1 defaults included (other people's drafts hidden unless `drafts=1`, `closed=1` shows the Recently Closed column). A dismissible chip names what -the bookmark applied. `src/legacy.ts` owns the translation. +the bookmark applied. `src/legacy.ts` owns the translation. (v2's own board +lens `#lens=board` merged into Classic; old links redirect.) ## Known gaps (backend additions v2 could use) @@ -68,5 +88,3 @@ the bookmark applied. `src/legacy.ts` owns the translation. chip falls back to size-only until it is. - **CR leaderboard** (v1's leader-list) and **desktop notifications** aren't ported yet. -- **Repo/author filter UI** — legacy URLs apply these filters, but v2 has no - menu to build them; the scope control covers people, not repos. diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index a3f8c029..5f8b0b8c 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -237,15 +237,44 @@ export function App() { mq.addEventListener('change', follow); return () => mq.removeEventListener('change', follow); }, []); - // v1's `/` hotkey: jump to the filter box from anywhere + // The keyboard model for an audience that lives in editors: + // / jump to the filter box (v1's hotkey) + // j/k move focus down/up the rows (Enter opens — it's a link) + // c copy the focused row's branch name useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key !== '/' || e.metaKey || e.ctrlKey) return; + if (e.metaKey || e.ctrlKey || e.altKey) return; const t = e.target as HTMLElement; if (['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName) || t.isContentEditable) return; - e.preventDefault(); - searchRef.current?.focus(); - searchRef.current?.select(); + if (e.key === '/') { + e.preventDefault(); + searchRef.current?.focus(); + searchRef.current?.select(); + return; + } + if (e.key === 'j' || e.key === 'k') { + const links = [ + ...document.querySelectorAll('.pd-row a[href*="/pull/"]'), + ]; + if (!links.length) return; + const at = links.indexOf(document.activeElement as HTMLAnchorElement); + const next = + at === -1 + ? e.key === 'j' + ? 0 + : links.length - 1 + : e.key === 'j' + ? Math.min(at + 1, links.length - 1) + : Math.max(at - 1, 0); + links[next]?.focus(); + e.preventDefault(); + return; + } + if (e.key === 'c') { + const row = (document.activeElement as HTMLElement | null)?.closest('.pd-row'); + const copy = row?.querySelector('button[aria-label^="copy branch"]'); + copy?.click(); + } }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index 74b5491b..c23939b2 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -13,7 +13,7 @@ export function ClosedRow({ pull, lastSeen }: { pull: PullData; lastSeen?: numbe const closedAt = Date.parse(pull.closed_at ?? pull.updated_at) / 1000; const fresh = lastSeen != null && closedAt > lastSeen; return ( -
+
{fresh && ( , 'Cryogenic Storage PRs and quiet repos, hidden by default')} + {item( + / j k c, + 'keys: / filter · j/k walk rows · Enter opens · c copies the branch' + )} )} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index b3994e18..cb430998 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -83,7 +83,7 @@ function WarnFlags({ pull, compact }: { pull: DerivedPull; compact?: boolean }) return ( <> {pull.conflict && pull.status !== 'unmergeable' && ( - + conflicts )} From 7a5ddb6f8ef3df29a035b106a4b6ddb81b3d1cc2 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 08:40:06 -0700 Subject: [PATCH 041/381] Tune the clocks to the shop's measured cadence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backtested the lane logic against three months of real history (1000 PRs, 845 merged): median first human review is under 2 hours, p75 under a day, p90 ~4 days; 62% of pulls merge same-day and 28% within two hours. The thresholds were tuned for a much slower shop: - STARVE_DAYS 7 -> 4 and the red line at 10: amber-at-a-week only flagged the p97 tail (33 PRs in 3 months), intervening after the author had lost the context. At 4 days the aging lane catches the p90 rot (74 PRs) while a nudge still lands. The queue's age credit sharpens with it. - The age slot shows hours under a day. With most of the board merging same-day, '0d' was a dead value on the majority of live rows; '2h' vs '9h' is the signal reviewers actually use. - isIterating keys on the push clock (headPushedAt) instead of updated_at. updated_at moves on every comment, so the demote-while- moving heuristic sank pulls exactly while reviewers were engaging with them — in a shop that reviews in hours, that was most pulls. Falls back to updated_at when CI hasn't reported a push time. - The bot fold leads with security updates: 'security' is the org's most-used label (50 in 3 months), nearly all on dependency bumps that were buried unsorted in the fold. Weight buckets were checked against the same data (XS 329 / S 179 / M 195 / L 60 / XL 45 across merged pulls) and left alone. Co-Authored-By: Claude Fable 5 --- frontend-v2/README.md | 7 +++++-- frontend-v2/src/components/Legend.tsx | 6 +++--- frontend-v2/src/components/Row.tsx | 8 +++++++- frontend-v2/src/components/bits.tsx | 26 +++++++++++++++++++------- frontend-v2/src/model/sort.test.ts | 2 +- frontend-v2/src/model/status.test.ts | 21 ++++++++++++++++++++- frontend-v2/src/model/status.ts | 23 +++++++++++++++++------ frontend-v2/src/views/Review.tsx | 14 ++++++++++++-- 8 files changed, 84 insertions(+), 23 deletions(-) diff --git a/frontend-v2/README.md b/frontend-v2/README.md index adaa0c2b..bd09fc9f 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -25,8 +25,11 @@ serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol underline, and clicking a slot lists who signed and when. - **A queue that ranks the right thing.** `src/model/sort.ts` scores by weight, then boosts pulls one stamp from done and credits age, so an old M - outranks a fresh S before the starvation cliff. Ages heat up (amber past a - week, red past two) with both clocks in the tooltip. + outranks a fresh S before the starvation cliff. Thresholds are tuned to + the shop's measured cadence (median first review under 2 hours, p90 ~4 + days): ages show hours under a day, heat amber past 4 days and red past + 10, with both clocks in the tooltip. "Iterating" keys on the push clock, + not updated_at, so a reviewer's comment can't sink a pull down the queue. - **Four lenses, one filter.** Review / My work / People (person or team drill-down) / Classic, over the same pool, with a single saved Scope (repos + people, with GitHub-team presets). The whole view — lens, query, diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index e699c48d..cd991063 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -60,10 +60,10 @@ export function Legend() { )} {item( - 9d/ - 15d + 5d/ + 12d , - 'age heats up: amber past a week, red past two. Hover for both clocks' + 'age: hours under a day, amber past 4 days, red past 10. Hover for both clocks' )} {item( M, diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index cb430998..cf309ab6 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -83,7 +83,12 @@ function WarnFlags({ pull, compact }: { pull: DerivedPull; compact?: boolean }) return ( <> {pull.conflict && pull.status !== 'unmergeable' && ( - + conflicts )} @@ -194,6 +199,7 @@ function Ledger({ pull, me }: { pull: DerivedPull; me: string }) { /> diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index c6e6f9dd..ff2b7744 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -1,4 +1,4 @@ -import type { Status, Weight } from '../model/status'; +import { ROT_DAYS, STARVE_DAYS, type Status, type Weight } from '../model/status'; import type { Signature } from '../types'; import { ago, githubUrl, loginHue, shortRepo } from '../format'; import { usePopover } from './usePopover'; @@ -270,29 +270,41 @@ export function SigPips({ } /** - * The age slot: open-days with an urgency ramp (quiet under a week, amber - * past STARVE_DAYS, red past two) and both clocks in the tooltip — a 30-day - * pull pushed an hour ago is hot, and the flat gray number hid that. + * The age slot: hours under a day, then days, with an urgency ramp (amber + * past STARVE_DAYS, red past ROT_DAYS) and both clocks in the tooltip — a + * 30-day pull pushed an hour ago is hot, and the flat gray number hid + * that. Hours matter here: in three months of real history, 62% of pulls + * merged same-day, so "0d" was a dead signal for most of the live board. */ export function AgeStamp({ ageDays, + createdAt, updatedAt, quiet, }: { ageDays: number; + /** epoch secs the pull opened */ + createdAt: number; /** epoch secs of the last activity */ updatedAt: number; /** drafts and holds age on purpose: no urgency color */ quiet?: boolean; }) { - const hot = quiet ? null : ageDays >= 14 ? 'var(--bad)' : ageDays >= 7 ? 'var(--warn)' : null; + const hot = quiet + ? null + : ageDays >= ROT_DAYS + ? 'var(--bad)' + : ageDays >= STARVE_DAYS + ? 'var(--warn)' + : null; + const text = ageDays === 0 ? ago(createdAt) : `${ageDays}d`; return ( - {ageDays}d + {text} ); } diff --git a/frontend-v2/src/model/sort.test.ts b/frontend-v2/src/model/sort.test.ts index 4faa5bdf..a3be54b8 100644 --- a/frontend-v2/src/model/sort.test.ts +++ b/frontend-v2/src/model/sort.test.ts @@ -21,7 +21,7 @@ function fake(over: { deletions: 0, // ancient updated_at so nothing counts as iterating updated_at: new Date(0).toISOString(), - status: { cr_req: over.crReq ?? 1 }, + status: { cr_req: over.crReq ?? 1, commit_statuses: [] }, }, } as unknown as DerivedPull; } diff --git a/frontend-v2/src/model/status.test.ts b/frontend-v2/src/model/status.test.ts index 1377d6c2..8dafd9f9 100644 --- a/frontend-v2/src/model/status.test.ts +++ b/frontend-v2/src/model/status.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { PullData, Signature, SignatureType } from '../types'; -import { ciVerdict, derive, reviewWeight } from './status'; +import { ciVerdict, derive, isIterating, reviewWeight } from './status'; const NOW = 1_800_000_000; @@ -321,6 +321,25 @@ describe('starvation and weight', () => { expect(derive(pull(), undefined, NOW).signedOffAt).toBeNull(); }); + it('iterating keys on the push clock, not comment activity', () => { + // pushed 5 minutes ago (head CI started then): iterating + const pushed = withStatus({ + commit_statuses: [{ data: { ...ci('pending').data, started_at: NOW - 300 } }], + }); + expect(isIterating(pushed, NOW)).toBe(true); + // pushed 2 days ago but commented on just now: NOT iterating + const commented = withStatus( + { + commit_statuses: [{ data: { ...ci('success').data, started_at: NOW - 2 * 86400 } }], + }, + { updated_at: new Date((NOW - 60) * 1000).toISOString() } + ); + expect(isIterating(commented, NOW)).toBe(false); + // no CI reported: falls back to updated_at + const bare = pull({ updated_at: new Date((NOW - 60) * 1000).toISOString() }); + expect(isIterating(bare, NOW)).toBe(true); + }); + it('names the failing CI checks', () => { const p = withStatus({ commit_statuses: [ci('failure', 'playwright'), ci('success', 'build')], diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index 081cb829..80d50b34 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -99,7 +99,15 @@ export interface DerivedPull { export type Weight = 'XS' | 'S' | 'M' | 'L' | 'XL'; const WEIGHT_RANK: Record = { XS: 0, S: 1, M: 2, L: 3, XL: 4 }; -export const STARVE_DAYS = 7; +/** + * Tuned to the shop's real cadence (3 months of history: median first + * review under 2 hours, p75 under a day, p90 ~4 days). A pull unreviewed + * at 4 days is already past p90 — waiting a week to call it starved meant + * intervening after the author lost the context. + */ +export const STARVE_DAYS = 4; +/** the age slot turns red here: past p97 of real first-review latency */ +export const ROT_DAYS = 10; const activeUsers = (sigs: Signature[]) => unique(sigs.filter(s => s.data.active).map(s => s.data.user.login)); @@ -280,11 +288,14 @@ export function reviewWeight(pull: PullData): Weight { export const weightRank = (w: Weight) => WEIGHT_RANK[w]; /** - * The author pushed or the pull changed in the last 30 minutes with nobody - * else active since: probably still iterating. The UI demotes (dims and - * sinks), never hides. + * The author pushed in the last 30 minutes: probably still iterating. The + * UI demotes (dims and sinks), never hides. Keyed to the PUSH clock, not + * updated_at — 62% of this shop's PRs merge same-day, and updated_at moves + * on every comment, so the old check sank pulls exactly while reviewers + * were engaging with them. Falls back to updated_at when no CI has + * reported a push time. */ export function isIterating(pull: PullData, now: number = Date.now() / 1000) { - const updated = Date.parse(pull.updated_at) / 1000; - return now - updated < 30 * 60; + const pushed = headPushedAt(pull) ?? Date.parse(pull.updated_at) / 1000; + return now - pushed < 30 * 60; } diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 29014215..6b55cce9 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -219,9 +219,19 @@ export function Review({ dot="var(--ink-3)" count={bots.length} label="bot PRs" - hint="dependency bumps, review in a batch" + hint="dependency bumps — security updates first" > - + {/* `security` is this org's most-used label (50 in 3 months), + almost all on bot bumps: they lead the fold */} + + Number(b.data.labels.some(l => /security/i.test(l.title))) - + Number(a.data.labels.some(l => /security/i.test(l.title))) || + b.ageDays - a.ageDays + )} + opts={opts} + /> Date: Fri, 17 Jul 2026 12:09:40 -0700 Subject: [PATCH 042/381] =?UTF-8?q?Unify=20the=20row=20across=20lenses;=20?= =?UTF-8?q?kill=20the=20=E2=9C=93/=E2=8A=98=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The item rendered differently in each lens (a dense one-line row in the lanes, a two-zone card in Classic), so the same PR read differently depending on where you saw it, and the review-effort weight only appeared pre-CR in the wide row. This makes one card the whole app's item: avatar and full-width title on top, one meta line below ending in a fixed metric rail — review effort, CR pips, QA pips, age — right-anchored so it scans as vertical columns down any board. Classic keeps its columns; the card inside them is now the same one the lanes render, which also gives every lens a real mobile layout (the old rows assumed >=860px and had no phone reflow). Sign-off state drops the ✓ and ⊘ glyphs for a pip meter: one square per required stamp, filled green for a live stamp, amber for a push-invalidated one (a re-stamp is owed), hollow for still-needed. Review effort becomes a five-segment light-to-heavy meter that's always present, not a pre-CR-only letter chip. The whole card is a click target now (the title link stretches over it), long text can't blow out the meta line (title wraps on its own line, notes and flag logins are capped), the "changed since your last look" control is a persistent toggle in the filter row instead of banner-only, and the lane/fold copy sheds the subtext that just restated the heading. Co-Authored-By: Claude Fable 5 --- frontend-v2/README.md | 31 +++- frontend-v2/src/app.tsx | 21 +-- frontend-v2/src/components/Card.tsx | 43 ++++-- frontend-v2/src/components/ClosedRow.tsx | 8 +- frontend-v2/src/components/Lane.tsx | 42 ++---- frontend-v2/src/components/Legend.tsx | 27 ++-- frontend-v2/src/components/Row.tsx | 183 ++++++++++------------- frontend-v2/src/components/bits.tsx | 137 +++++++++++------ frontend-v2/src/styles.css | 48 +++++- frontend-v2/src/views/Classic.tsx | 23 +-- frontend-v2/src/views/MyWork.tsx | 27 +--- frontend-v2/src/views/People.tsx | 21 +-- frontend-v2/src/views/Review.tsx | 51 ++----- 13 files changed, 336 insertions(+), 326 deletions(-) diff --git a/frontend-v2/README.md b/frontend-v2/README.md index bd09fc9f..3e72044d 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -18,11 +18,27 @@ serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol re-stamps you owe (CR and QA), your merge buttons, your CI fixes — regardless of who authored the pull. `src/model/actions.ts` is the single source for "whose move is it", shared with My work. +- **One item, one order, everywhere.** Every lens renders the same card: + avatar and full-width title on top, one meta line below (status, repo, + context, flags) ending in a fixed metric rail — review effort, then CR and + QA sign-off pips, then age — right-anchored so the rail reads as vertical + columns down any board. Giving the title its own line is what lets a long + title wrap cleanly and what lets the whole thing reflow to a phone; + Classic keeps its columns, but the card inside them is the same one the + lanes use. The whole card is a click target (the title link stretches over + it), so a click anywhere opens the PR. - **The re-stamp lane.** Inactive CR/QA signatures (invalidated by a push) are already on the wire; a pull that was reviewed and fixed but lacks a - fresh stamp is the cheapest review on the board, so it leads. The ledger - shows partial staleness (`1/2⊘`), marks slots you stamped with a dotted - underline, and clicking a slot lists who signed and when. + fresh stamp is the cheapest review on the board, so it leads. Sign-off is a + pip meter — one square per required stamp: filled green is a live stamp, + amber is a stamp a push invalidated (a re-stamp is owed, the board's most + actionable state), hollow is still needed. A dotted underline marks a slot + you stamped; clicking the pips lists who signed and when. No check glyph, no + slash — the fill is the whole vocabulary. +- **Review effort, always shown.** A five-segment meter (light to heavy, + color-ramped) rides the rightmost rail of every row, so "can I fit this in + the time I have" reads without opening the diff. A cheap prior from diff + size; humans override by reading. - **A queue that ranks the right thing.** `src/model/sort.ts` scores by weight, then boosts pulls one stamp from done and credits age, so an old M outranks a fresh S before the starvation cliff. Thresholds are tuned to @@ -43,8 +59,9 @@ serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol - **Changed since your last look.** A last-seen marker in localStorage, stamped when you leave (pagehide) and only after ~45 visible seconds, so a glance at another tab can't erase the weekend's delta. Solid dot = new PR, - ring = updated; opening a PR clears its dot; the banner also counts merges - while you were away. + ring = updated; opening a PR clears its dot. A persistent `● changed N` + toggle sits in the filter row (not just the arrival banner) to narrow the + board to those PRs, and the banner also counts merges while you were away. - **Keyboard:** `/` filter, `j`/`k` walk rows, `Enter` opens, `c` copies the focused row's branch. @@ -87,7 +104,7 @@ lens `#lens=board` merged into Classic; old links redirect.) - **Comment/review activity isn't on the wire** — cues like "X commented 2h ago" need the server to include recent comment metadata in `toObject()`. - **Teams over the handshake** instead of a static file (see above). -- **`changed_files`** is stored in the DB but not sent; the review-weight - chip falls back to size-only until it is. +- **`changed_files`** is stored in the DB but not sent; the review-effort + meter falls back to size-only until it is. - **CR leaderboard** (v1's leader-list) and **desktop notifications** aren't ported yet. diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 5f8b0b8c..325be802 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -429,8 +429,18 @@ export function App() { title="text, #number, label:x, status:x, older:5, repo:x, author:x" value={query} onChange={e => setQuery(e.target.value)} - className="h-8 w-[170px] rounded-lg border border-line bg-surface px-2.5 text-[13px]" + className="h-8 w-[170px] max-w-full grow rounded-lg border border-line bg-surface px-2.5 text-[13px] sm:grow-0" /> + {(changedCount > 0 || onlyChanged) && ( + setOnlyChanged(v => !v)} + title="PRs updated since your last visit. The marker only advances after you've had the board open a while, so a quick glance won't clear the weekend's changes. Click to show just these." + > + + changed{changedCount > 0 ? ` ${changedCount}` : ''} + + )} {legacy && ( )} - {onlyChanged && ( - setOnlyChanged(false)} - title="showing only PRs changed since your last look. Click to show everything" - > - changed only ✕ - - )} {hiddenCount > 0 && ( void; @@ -27,16 +35,18 @@ export function CardShell({ fresh?: 'new' | 'updated' | null; onOpen?: () => void; className?: string; - right: ReactNode; + /** the whole meta line: badge, repo#number, context, flags, metric rail */ + meta: ReactNode; + stretch?: boolean; }) { return (
{/* lives in the padding gutter: a changed card must not indent its content */} {fresh && ( )} - + - + - - - {right} + + {meta}
diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index c23939b2..d76f7e10 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -28,11 +28,13 @@ export function ClosedRow({ pull, lastSeen }: { pull: PullData; lastSeen?: numbe > {merged ? 'Merged' : 'Closed'}
- + + + - + - + {ago(closedAt)} ago diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index 3233b227..b8e6c4c2 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -19,7 +19,7 @@ export function Lane({ children, }: { title: string; - sub: string; + sub?: string; pulls: DerivedPull[]; /** header count when rows come in via children instead of pulls */ count?: number; @@ -33,7 +33,7 @@ export function Lane({

{title}

- {sub} + {sub && {sub}} {count ?? pulls.length}
@@ -50,9 +50,10 @@ export function Lane({ } /** - * A row with the lane's explanation column beside it: the action verb on - * the left ("Merge it", "Re-stamp"), or the who-to-nudge note on the right. - * The row itself drops its cue — the annotation carries it. + * A row carrying the lane's explanation in its own context slot: the action + * verb ("Merge it", "Re-stamp") for your move, or the who-to-nudge note for + * work you're waiting on. Folding it into the meta line (instead of a fixed + * side column) is what lets these rows reflow to a phone. */ export function AnnotatedRow({ pull, @@ -65,33 +66,8 @@ export function AnnotatedRow({ side: 'left' | 'right'; children: ReactNode; }) { - const row = ( - - - - ); return ( -
- {side === 'left' ? ( - <> - - {children} - - {row} - - ) : ( - <> - {row} - - {children} - - - )} -
+ ); } @@ -170,7 +146,7 @@ export function Fold({ dot: string; count: number; label: string; - hint: string; + hint?: string; children: ReactNode; }) { if (!count) return null; @@ -182,7 +158,7 @@ export function Fold({ {count} {label} - {hint} + {hint && {hint}}
{children}
diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index cd991063..c89ed200 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -1,3 +1,4 @@ +import { WeightMeter } from './bits'; import { usePopover } from './usePopover'; /** @@ -43,19 +44,24 @@ export function Legend() { 'a CR or QA sign-off, left as a comment on the PR' )} {item( - - CR 1/2 + + CR + + , - 'sign-offs given / required; ✓ done, – not required. Click for who signed' + 'CR / QA sign-offs: one pip per required stamp, filled = done, hollow = still needed. Click a row’s pips for who signed' )} {item( - - 1/2 + + + , - 'a push invalidated a stamp: someone owes a re-stamp' + 'an amber pip means a push invalidated a stamp: a re-stamp is owed' )} {item( - 1/2, + + + , 'the dotted underline marks a slot you stamped' )} {item( @@ -65,10 +71,7 @@ export function Legend() { , 'age: hours under a day, amber past 4 days, red past 10. Hover for both clocks' )} - {item( - M, - 'estimated review effort (XS to XL), from diff size' - )} + {item(, 'review effort, light to heavy, from diff size')} {item( QAing, 'someone claimed QA by adding the QAing label on GitHub (◉ in columns)' @@ -94,7 +97,7 @@ export function Legend() { , - 'since your last look: solid = new PR, ring = updated. Opening one clears it' + 'changed since your last visit: solid = new PR, ring = updated (tracked while you’re away). Opening one clears it' )} {item(, 'Cryogenic Storage PRs and quiet repos, hidden by default')} {item( diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index cf309ab6..4cf3ce5e 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -1,9 +1,9 @@ -import { memo, useState } from 'react'; +import { memo, useState, type ReactNode } from 'react'; import type { DerivedPull } from '../model/status'; import { isIterating } from '../model/status'; import { ago, pullKey } from '../format'; import { ackPull, isFresh, refreshPull } from '../store'; -import { AgeStamp, Avatar, PullTitleLink, RepoRef, SigPips, StatusBadge, WeightChip } from './bits'; +import { AgeStamp, RepoRef, SigPips, StatusBadge, WeightMeter } from './bits'; import { CardShell } from './Card'; export interface RowOptions { @@ -11,13 +11,6 @@ export interface RowOptions { badge?: boolean; /** show the open-Nd flag on starved pulls */ aging?: boolean; - /** - * Column mode (Classic): the title owns the space. Drops the cue line, - * weight chip, and hover actions — a full row's anatomy crushes titles - * to two letters inside a 340px column. Warning flags stay: conflicts - * and holds change who acts, and columns are where deployers look. - */ - compact?: boolean; /** suppress the cue line when the view says the same thing beside the row */ cue?: boolean; me: string; @@ -78,8 +71,17 @@ function cue(p: DerivedPull, me: string): string | null { return null; } +/** A login that can't blow out the meta line. */ +function Who({ login }: { login: string }) { + return ( + + {login} + + ); +} + /** Small warning flags that ride along regardless of lane. */ -function WarnFlags({ pull, compact }: { pull: DerivedPull; compact?: boolean }) { +function WarnFlags({ pull }: { pull: DerivedPull }) { return ( <> {pull.conflict && pull.status !== 'unmergeable' && ( @@ -97,7 +99,7 @@ function WarnFlags({ pull, compact }: { pull: DerivedPull; compact?: boolean }) className="flag-amber" title={`based on ${pull.data.base.ref}, lands with its parent`} > - {compact ? 'dep' : 'dependent'} + dependent )} {pull.deployBlockedBy.length > 0 && pull.status !== 'deploy_block' && ( @@ -113,22 +115,22 @@ function WarnFlags({ pull, compact }: { pull: DerivedPull; compact?: boolean }) mergeable? )} - {!compact && pull.ci === 'pending' && pull.status !== 'ci_pending' && ( - + {pull.ci === 'pending' && pull.status !== 'ci_pending' && ( + CI… )} {pull.externalBlock && ( - {compact ? 'ext' : 'external'} + external )} {pull.qaingBy && pull.status === 'needs_qa' && ( - {compact ? `◉ ${pull.qaingBy}` : `QAing: ${pull.qaingBy}`} + QAing: )} @@ -139,7 +141,7 @@ function WarnFlags({ pull, compact }: { pull: DerivedPull; compact?: boolean }) function RowActions({ pull }: { pull: DerivedPull }) { const [copied, setCopied] = useState(false); return ( - +
- + {(rest.length > 0 || owed.length > 0 || mine.length > 0) && ( {selectedPerson && ( - + )} diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 6b55cce9..e1319fdc 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -113,13 +113,7 @@ export function Review({ return ( <> {todo.length > 0 && ( - + {todo.map(({ p, verb }) => ( @@ -129,33 +123,26 @@ export function Review({ )} - + - + @@ -167,19 +154,14 @@ export function Review({ > - + @@ -187,7 +169,7 @@ export function Review({ dot={STATUS_DOT.unmergeable} count={unmergeable.length} label="can't merge" - hint="signed off but conflicted or dependent — the author rebases" + hint="the author rebases" > @@ -195,32 +177,21 @@ export function Review({ dot={STATUS_DOT.ci_pending} count={ciPending.length} label={STATUS_LABEL.ci_pending.toLowerCase()} - hint="signed off, waiting on green" + hint="waiting on green" > - + - + {/* `security` is this org's most-used label (50 in 3 months), almost all on bot bumps: they lead the fold */} Date: Fri, 17 Jul 2026 12:39:38 -0700 Subject: [PATCH 043/381] Make "hidden" an eye selector, and surface hidden repos in Scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snowflake chip was all-or-nothing: it revealed every off-by-default PR at once, and the two groups it lumped together (Cryogenic-Storage PRs and hide-by-default repos) had no way to be shown independently. It also gave no hint that hidden repos existed at all — you couldn't scope to one without knowing its name. Replace it with an eye-icon selector: a popover that shows the count per group and lets you reveal everything, or just cryo, or just a specific quiet repo, encoded in the hash as `show=cryo,someRepo` (the old `hidden=1` still means "show all"). Hidden repos are now also seeded into the Scope control's repo list and marked, so a hidden repo can be scoped onto the board even when it has no open PRs today — scoping a repo already overrides its hidden state. Co-Authored-By: Claude Fable 5 --- frontend-v2/README.md | 6 + frontend-v2/src/app.tsx | 92 ++++++++---- frontend-v2/src/components/HiddenSelector.tsx | 133 ++++++++++++++++++ frontend-v2/src/components/Legend.tsx | 14 +- frontend-v2/src/components/Scope.tsx | 24 +++- 5 files changed, 240 insertions(+), 29 deletions(-) create mode 100644 frontend-v2/src/components/HiddenSelector.tsx diff --git a/frontend-v2/README.md b/frontend-v2/README.md index 3e72044d..045181cb 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -52,6 +52,12 @@ serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol scope, toggles — lives in the URL hash: any board is pasteable and a bookmark is a saved view. The filter understands `#number`, `label:x`, `status:x`, `older:5`, `repo:x`, and `author:x`. +- **An eye, not a snowflake.** Two kinds of PR hide by default — + Cryogenic-Storage (deliberately parked) and hide-by-default repos (quiet + noise). The visibility control is a selector, not an all-or-nothing toggle: + reveal everything, or one group at a time (`#show=cryo,someRepo`). Hidden + repos are listed and marked in the Scope control too, so you can scope one + onto the board directly. - **Classic is v1, faithfully.** The same six overlapping columns (CI Blocked / Deploy Blocked / Ready / Dev Block / CR / QA), predicates and sorts ported line-for-line from v1, for anyone whose muscle memory diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 325be802..8614d745 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -8,6 +8,7 @@ import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy import { loadSiteConfig, primeScope, useScope } from './prefs'; import { matchesQuery } from './model/query'; import { Legend } from './components/Legend'; +import { CRYO_KEY, HiddenSelector } from './components/HiddenSelector'; import { ScopeControl } from './components/Scope'; import { STATUS_LABEL } from './components/bits'; import type { RowOptions } from './components/Row'; @@ -33,7 +34,10 @@ interface HashState { q: string; repos: string[]; authors: string[]; + /** master reveal: show every off-by-default PR */ hidden: boolean; + /** individually revealed hidden groups: repo names and the cryo sentinel */ + reveal: string[]; changed: boolean; } @@ -51,6 +55,7 @@ function readHash(): HashState { repos: p.get('repos')?.split(',').filter(Boolean) ?? [], authors: p.get('authors')?.split(',').filter(Boolean) ?? [], hidden: p.get('hidden') === '1', + reveal: p.get('show')?.split(',').filter(Boolean) ?? [], changed: p.get('changed') === '1', }; } @@ -64,6 +69,7 @@ function buildHash(s: HashState): string { if (s.repos.length) p.set('repos', s.repos.join(',')); if (s.authors.length) p.set('authors', s.authors.join(',')); if (s.hidden) p.set('hidden', '1'); + if (s.reveal.length) p.set('show', s.reveal.join(',')); if (s.changed) p.set('changed', '1'); return p.toString(); } @@ -156,9 +162,17 @@ export function App() { const [team, setTeam] = useState(() => urlState.team); const [query, setQuery] = useState(() => urlState.q); const [onlyChanged, setOnlyChanged] = useState(() => urlState.changed); - const [showHidden, setShowHidden] = useState( + // "hidden" is two off-by-default groups (Cryogenic-Storage PRs, quiet + // repos). showAll reveals both; reveal names individual groups to show. + const [showAll, setShowAll] = useState( () => urlState.hidden || (!!legacy && (legacy.cryo || legacy.showAllRepos)) ); + const [reveal, setReveal] = useState(() => urlState.reveal); + const toggleReveal = useCallback( + (key: string) => + setReveal(cur => (cur.includes(key) ? cur.filter(k => k !== key) : [...cur, key])), + [] + ); const [teams, setTeams] = useState([]); const [extraBots, setExtraBots] = useState>(new Set()); const isBot = useCallback( @@ -195,7 +209,8 @@ export function App() { q: query, repos: scope.repos, authors: scope.authors, - hidden: showHidden, + hidden: showAll, + reveal, changed: onlyChanged, }); if (next === location.hash.slice(1)) return; @@ -207,7 +222,7 @@ export function App() { } else { history.replaceState(null, '', next ? `#${next}` : location.pathname + location.search); } - }, [lens, person, team, query, scope, showHidden, onlyChanged]); + }, [lens, person, team, query, scope, showAll, reveal, onlyChanged]); useEffect(() => { const onHash = () => { const h = readHash(); @@ -215,7 +230,8 @@ export function App() { setPerson(h.person); setTeam(h.team); setQuery(h.q); - setShowHidden(h.hidden); + setShowAll(h.hidden); + setReveal(h.reveal); setOnlyChanged(h.changed); }; window.addEventListener('hashchange', onHash); @@ -292,16 +308,19 @@ export function App() { let out = pulls; if (legacy) out = applyLegacyFilters(out, legacy, me); // v1 conventions: Cryogenic-Storage pulls and hideByDefault repos stay - // off the board unless asked for (or the scope names the repo). - if (!showHidden) - out = out.filter( - p => - !p.cryo && - (!hiddenRepos.has(p.data.repo) || - scope.repos.includes(p.data.repo) || - // a legacy URL naming the repo means "show it", hidden or not - legacy?.repos.includes(shortRepo(p.data.repo))) - ); + // off the board unless revealed (all at once, per-group, or by scoping + // the repo). + if (!showAll) + out = out.filter(p => { + const cryoHidden = p.cryo && !reveal.includes(CRYO_KEY); + const repoHidden = + hiddenRepos.has(p.data.repo) && + !reveal.includes(p.data.repo) && + !scope.repos.includes(p.data.repo) && + // a legacy URL naming the repo means "show it", hidden or not + !legacy?.repos.includes(shortRepo(p.data.repo)); + return !cryoHidden && !repoHidden; + }); if (scope.repos.length) out = out.filter(p => scope.repos.includes(p.data.repo)); // bots bypass the people filter on purpose: dependency bumps need review // no matter whose work you follow (they land in the bots fold, not lanes) @@ -309,7 +328,7 @@ export function App() { out = out.filter(p => isBot(p) || scope.authors.includes(p.data.user.login)); if (query) out = out.filter(p => matchesQuery(p, query)); return out; - }, [pulls, scope, query, showHidden, hiddenRepos, legacy, me, isBot]); + }, [pulls, scope, query, showAll, reveal, hiddenRepos, legacy, me, isBot]); // changed-only and the banner count share one predicate (bots excluded, // acked rows drop out) so the toggle always shows exactly what the banner @@ -328,9 +347,26 @@ export function App() { const mergedCount = closed.filter( p => (Date.parse(p.closed_at ?? '') / 1000 || 0) > lastSeen ).length; - const hiddenCount = pulls.filter( - p => p.cryo || (hiddenRepos.has(p.data.repo) && !scope.repos.includes(p.data.repo)) - ).length; + // the two hidden groups, and how many PRs each holds, for the selector. + // hiddenCount is how many are hidden *right now* given the reveal state. + const cryoCount = pulls.filter(p => p.cryo).length; + const hiddenRepoCounts = useMemo(() => { + const m = new Map(); + for (const name of hiddenRepos) m.set(name, 0); + for (const p of pulls) + if (hiddenRepos.has(p.data.repo)) m.set(p.data.repo, (m.get(p.data.repo) ?? 0) + 1); + return [...m.entries()].sort((a, b) => b[1] - a[1]); + }, [pulls, hiddenRepos]); + const hiddenCount = showAll + ? 0 + : pulls.filter(p => { + const cryoHidden = p.cryo && !reveal.includes(CRYO_KEY); + const repoHidden = + hiddenRepos.has(p.data.repo) && + !reveal.includes(p.data.repo) && + !scope.repos.includes(p.data.repo); + return cryoHidden || repoHidden; + }).length; const statusCounts = new Map(); for (const p of humans) statusCounts.set(p.status, (statusCounts.get(p.status) ?? 0) + 1); @@ -420,7 +456,7 @@ export function App() { {tab('people', 'People')} {tab('classic', 'Classic')} - + ✕ )} - {hiddenCount > 0 && ( - setShowHidden(v => !v)} - title="Cryogenic-Storage PRs and hide-by-default repos" - > - ❄ {hiddenCount} hidden - + {(cryoCount > 0 || hiddenRepoCounts.length > 0) && ( + )} {bots.length > 0 && ( diff --git a/frontend-v2/src/components/HiddenSelector.tsx b/frontend-v2/src/components/HiddenSelector.tsx new file mode 100644 index 00000000..57bd605d --- /dev/null +++ b/frontend-v2/src/components/HiddenSelector.tsx @@ -0,0 +1,133 @@ +import { shortRepo } from '../format'; +import { usePopover } from './usePopover'; + +/** Open eye = revealing, slashed eye = hidden. */ +function Eye({ off }: { off?: boolean }) { + return ( + + + {off && ( + + )} + + ); +} + +/** the sentinel key for the Cryogenic-Storage group in the reveal set */ +export const CRYO_KEY = 'cryo'; + +/** + * The visibility control: which off-by-default PRs to show. Two kinds hide by + * default — Cryogenic-Storage PRs (deliberately parked) and hide-by-default + * repos (low-traffic noise) — and this is where you reveal them, all at once + * or one group at a time, instead of the old all-or-nothing snowflake toggle. + */ +export function HiddenSelector({ + count, + cryo, + repos, + showAll, + reveal, + onShowAll, + onToggle, +}: { + /** how many PRs are hidden right now, given the current reveal state */ + count: number; + /** total Cryogenic-Storage PRs (0 hides the row) */ + cryo: number; + /** hide-by-default repos with their open-PR counts */ + repos: [string, number][]; + /** master: reveal everything hidden */ + showAll: boolean; + /** individually revealed keys (repo names and CRYO_KEY) */ + reveal: string[]; + onShowAll: (next: boolean) => void; + onToggle: (key: string) => void; +}) { + const pop = usePopover(); + const revealing = showAll || reveal.length > 0; + + const row = (key: string, label: string, n: number) => ( + + ); + + return ( + + + {pop.open && ( + + + Off-by-default PRs. Reveal what you want to see. + + + {cryo > 0 && ( + <> + + Cryogenic Storage + + {row(CRYO_KEY, 'parked PRs', cryo)} + + )} + {repos.length > 0 && ( + <> + + Quiet repos + + {repos.map(([name, n]) => row(name, shortRepo(name), n))} + + )} + + )} + + ); +} diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index c89ed200..2f009755 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -99,7 +99,19 @@ export function Legend() { , 'changed since your last visit: solid = new PR, ring = updated (tracked while you’re away). Opening one clears it' )} - {item(, 'Cryogenic Storage PRs and quiet repos, hidden by default')} + {item( + + + + , + 'the eye selector reveals off-by-default PRs: Cryogenic Storage and quiet repos' + )} {item( / j k c, 'keys: / filter · j/k walk rows · Enter opens · c copies the branch' diff --git a/frontend-v2/src/components/Scope.tsx b/frontend-v2/src/components/Scope.tsx index 28ce6fa3..bd0caa13 100644 --- a/frontend-v2/src/components/Scope.tsx +++ b/frontend-v2/src/components/Scope.tsx @@ -9,7 +9,16 @@ import { usePopover } from './usePopover'; * A full selection commits as empty ("everything") so new repos and new * teammates never get silently excluded by a stale saved list. */ -export function ScopeControl({ pulls, teams }: { pulls: DerivedPull[]; teams: Team[] }) { +export function ScopeControl({ + pulls, + teams, + hiddenRepos, +}: { + pulls: DerivedPull[]; + teams: Team[]; + /** hide-by-default repos: shown here too, marked, so you can scope one in */ + hiddenRepos?: ReadonlySet; +}) { const [scope, setScope] = useScope(); const { open, @@ -34,6 +43,10 @@ export function ScopeControl({ pulls, teams }: { pulls: DerivedPull[]; teams: Te for (const name of scope.repos) { if (!repoCounts.has(name)) repoCounts.set(name, 0); } + // hide-by-default repos belong here too, so a hidden repo can be scoped in + // even when it has no open PRs today + if (hiddenRepos) + for (const name of hiddenRepos) if (!repoCounts.has(name)) repoCounts.set(name, 0); const repos = [...repoCounts.entries()].sort((a, b) => b[1] - a[1]); const authors = [...authorCounts.entries()].sort((a, b) => b[1] - a[1]); @@ -81,6 +94,15 @@ export function ScopeControl({ pulls, teams }: { pulls: DerivedPull[]; teams: Te {strip ? shortRepo(name) : name} + {hiddenRepos?.has(name) && ( + + hidden + + )} {me ? `signed in as ${me}` : '…'} v1 board - +
)} + {initialized && lens === 'stats' && ( + + )}
); diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts index 8807e7dd..c26494a7 100644 --- a/frontend-v2/src/backend/dummy.ts +++ b/frontend-v2/src/backend/dummy.ts @@ -15,13 +15,18 @@ export function dummyUser(): string { } export async function loadDummy(): Promise { - const pulls = (await import('../../../frontend/dummy-pulls.json')) + const raw = (await import('../../../frontend/dummy-pulls.json')) .default as unknown as PullData[]; + // The fixture is a decade of frozen pulls; re-date them so age-derived + // signals (heat, starvation, freshness) exercise realistically. + const pulls = raw.map((p, i) => redate(p, i)); return { repos: [{ name: 'iFixit/ifixit' }], - // The fixture is a decade of frozen pulls; re-date them so age-derived - // signals (heat, starvation, freshness) exercise realistically. - pulls: pulls.map((p, i) => redate(p, i)), + // The fixture carries almost no closed/merged pulls and no diff sizes, so + // the Stats lens (merge-time-by-size, leaderboards over shipped work) has + // nothing to show. Synthesize a fortnight of merged PRs across the size + // range so those panels demo. Dummy-only — real data carries this for real. + pulls: [...pulls, ...synthMerged(pulls)], }; } @@ -30,9 +35,38 @@ function redate(pull: PullData, i: number): PullData { const ageDays = (i * 7919) % 45; // deterministic spread, 0-45 days const created = new Date(now - ageDays * 86400_000); const updated = new Date(now - ((i * 104729) % (72 * 3600_000))); + // synthesize a diff size when the fixture omits one, so the weight meter + // and size-derived stats aren't all flat XS in dummy mode + const additions = pull.additions ?? 17 + ((i * 4099) % 1600); + const deletions = pull.deletions ?? (i * 1237) % 400; return { ...pull, created_at: created.toISOString(), updated_at: updated.toISOString(), + additions, + deletions, }; } + +// Clone a slice of the board into merged PRs, closed within the last 14 days, +// with merge time that rises with size (plus jitter) so the trend is visible. +function synthMerged(pulls: PullData[]): PullData[] { + const now = Date.now(); + return pulls.slice(0, 16).map((p, i) => { + const size = (p.additions ?? 0) + (p.deletions ?? 0); + const mergeHours = 1.5 + size / 45 + ((i * 53) % 34); + const closedDaysAgo = (i * 3) % 14; + const merged = now - closedDaysAgo * 86400_000; + const created = merged - mergeHours * 3_600_000; + return { + ...p, + number: 90000 + i, + state: 'closed', + draft: false, + created_at: new Date(created).toISOString(), + updated_at: new Date(merged).toISOString(), + closed_at: new Date(merged).toISOString(), + merged_at: new Date(merged).toISOString(), + }; + }); +} diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 2f009755..da7498af 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -1,3 +1,4 @@ +import { useSettings } from '../settings'; import { WeightMeter } from './bits'; import { usePopover } from './usePopover'; @@ -8,6 +9,7 @@ import { usePopover } from './usePopover'; */ export function Legend() { const pop = usePopover(); + const s = useSettings(); const item = (term: React.ReactNode, def: string) => (
@@ -66,10 +68,10 @@ export function Legend() { )} {item( - 5d/ - 12d + {s.ageWarnDays}d/ + {s.ageRotDays}d , - 'age: hours under a day, amber past 4 days, red past 10. Hover for both clocks' + `age: hours under a day, amber past ${s.ageWarnDays} days, red past ${s.ageRotDays}. Hover for both clocks` )} {item(, 'review effort, light to heavy, from diff size')} {item( diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 4cf3ce5e..0bdde9ef 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -18,6 +18,9 @@ export interface RowOptions { /** pull keys opened this session (their fresh dots are cleared) */ acked: ReadonlySet; onPerson?: (login: string) => void; + /** age-color thresholds from user settings (fall back to the model's) */ + ageWarnDays?: number; + ageRotDays?: number; } /** @@ -182,8 +185,9 @@ function RowActions({ pull }: { pull: DerivedPull }) { * fixed-geometry, so it reads as vertical columns down any lens. Raised above * the card's click layer so the sign-off popovers still open. */ -function MetricRail({ pull, me }: { pull: DerivedPull; me: string }) { +function MetricRail({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const d = pull.data; + const me = opts.me; return ( @@ -211,6 +215,8 @@ function MetricRail({ pull, me }: { pull: DerivedPull; me: string }) { createdAt={Date.parse(d.created_at) / 1000} updatedAt={Date.parse(d.updated_at) / 1000} quiet={['draft', 'dev_block', 'deploy_block'].includes(pull.status)} + warnDays={opts.ageWarnDays} + rotDays={opts.ageRotDays} /> ); @@ -275,7 +281,7 @@ function RowImpl({ )} - + } /> @@ -299,5 +305,7 @@ export const Row = memo( a.opts.me === b.opts.me && a.opts.lastSeen === b.opts.lastSeen && a.opts.acked === b.opts.acked && - a.opts.onPerson === b.opts.onPerson + a.opts.onPerson === b.opts.onPerson && + a.opts.ageWarnDays === b.opts.ageWarnDays && + a.opts.ageRotDays === b.opts.ageRotDays ); diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx new file mode 100644 index 00000000..9edd7b6f --- /dev/null +++ b/frontend-v2/src/components/Settings.tsx @@ -0,0 +1,275 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { markAllSeen } from '../store'; +import { type Settings as SettingsShape, setSettings, useSettings } from '../settings'; + +const LENS_OPTIONS: [string, string][] = [ + ['review', 'Review'], + ['mine', 'My work'], + ['people', 'People'], + ['classic', 'Classic'], + ['stats', 'Stats'], +]; + +/** A labelled group of controls inside the panel. */ +function Group({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +/** One setting: a label (+ optional hint) over its control. */ +function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { + return ( +
+
+ {label} + {children} +
+ {hint && {hint}} +
+ ); +} + +/** A pill segmented control — the house pattern for a small closed choice. */ +function Segmented({ + value, + options, + onChange, + ariaLabel, +}: { + value: T; + options: [T, string][]; + onChange: (next: T) => void; + ariaLabel: string; +}) { + return ( +
+ {options.map(([val, label]) => ( + + ))} +
+ ); +} + +/** A small bounded number stepper (days, seconds). */ +function NumberField({ + value, + min, + max, + suffix, + onChange, +}: { + value: number; + min: number; + max: number; + suffix: string; + onChange: (next: number) => void; +}) { + return ( + + { + const n = Number(e.target.value); + if (Number.isFinite(n)) onChange(Math.min(max, Math.max(min, Math.round(n)))); + }} + className="h-8 w-16 rounded-lg border border-line bg-surface px-2 text-right text-[13px] tabular-nums" + /> + {suffix} + + ); +} + +export function Settings() { + const [open, setOpen] = useState(false); + const s = useSettings(); + const panelRef = useRef(null); + const triggerRef = useRef(null); + const [seenNote, setSeenNote] = useState(false); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('keydown', onKey); + // focus the panel so Escape and tabbing land inside it + panelRef.current?.focus(); + return () => document.removeEventListener('keydown', onKey); + }, [open]); + + // return focus to the cog when the panel closes + useEffect(() => { + if (!open) triggerRef.current?.focus?.(); + }, [open]); + + const set = (patch: Partial) => setSettings(patch); + + return ( + <> + + {open && + createPortal( +
+
setOpen(false)} + aria-hidden + /> +
+
+ Settings + +
+ + + + set({ theme })} + /> + + + set({ density })} + /> + + + + + + set({ defaultLens })} + /> + + + set({ ageWarnDays })} + /> + + + set({ ageRotDays })} + /> + + + + + + set({ seenAfterSecs })} + /> + +
+ + {seenNote && ( + + done + + )} +
+
+
+
, + document.body + )} + + ); +} diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 29e485fc..28a96478 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -311,6 +311,8 @@ export function AgeStamp({ createdAt, updatedAt, quiet, + warnDays = STARVE_DAYS, + rotDays = ROT_DAYS, }: { ageDays: number; /** epoch secs the pull opened */ @@ -319,12 +321,16 @@ export function AgeStamp({ updatedAt: number; /** drafts and holds age on purpose: no urgency color */ quiet?: boolean; + /** amber at/after this many days (user setting; defaults to the model's) */ + warnDays?: number; + /** red at/after this many days */ + rotDays?: number; }) { const hot = quiet ? null - : ageDays >= ROT_DAYS + : ageDays >= rotDays ? 'var(--bad)' - : ageDays >= STARVE_DAYS + : ageDays >= warnDays ? 'var(--warn)' : null; const text = ageDays === 0 ? ago(createdAt) : `${ageDays}d`; diff --git a/frontend-v2/src/model/stats.test.ts b/frontend-v2/src/model/stats.test.ts new file mode 100644 index 00000000..9025dd62 --- /dev/null +++ b/frontend-v2/src/model/stats.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import type { PullData } from '../types'; +import type { DerivedPull, Status } from './status'; +import { crStarvation, humanHours, mergeTimeBySize, signoffLeaders } from './stats'; + +function sig(login: string) { + return { data: { user: { login } } }; +} + +function open(over: { + status?: Status; + author?: string; + ageDays?: number; + cr?: string[]; + qa?: string[]; + n?: number; +}): DerivedPull { + return { + status: over.status ?? 'needs_cr', + ageDays: over.ageDays ?? 0, + data: { + repo: 'iFixit/ifixit', + number: over.n ?? 1, + user: { login: over.author ?? 'alice' }, + status: { + allCR: (over.cr ?? []).map(sig), + allQA: (over.qa ?? []).map(sig), + }, + }, + } as unknown as DerivedPull; +} + +function merged(over: { n: number; add: number | null; hours: number }): PullData { + const created = 1_700_000_000_000; + return { + repo: 'iFixit/ifixit', + number: over.n, + merged_at: new Date(created + over.hours * 3_600_000).toISOString(), + created_at: new Date(created).toISOString(), + additions: over.add, + // unsized on the wire means both are absent, not zero + deletions: over.add == null ? null : 0, + changed_files: 1, + status: { allCR: [], allQA: [] }, + } as unknown as PullData; +} + +describe('signoffLeaders', () => { + it('counts distinct PRs per reviewer across open and closed', () => { + const pulls = [ + open({ n: 1, cr: ['bob', 'carol'] }), + open({ n: 2, cr: ['bob', 'bob'] }), // a re-stamp is still one PR + ]; + const closed = [{ ...pulls[0].data, number: 3, status: { allCR: [sig('bob')], allQA: [] } }]; + const leaders = signoffLeaders(pulls, closed as unknown as PullData[], 'CR'); + expect(leaders[0]).toEqual({ login: 'bob', count: 3 }); + expect(leaders.find(l => l.login === 'carol')?.count).toBe(1); + }); + + it('separates CR from QA', () => { + const pulls = [open({ n: 1, cr: ['bob'], qa: ['dave'] })]; + expect(signoffLeaders(pulls, [], 'QA')).toEqual([{ login: 'dave', count: 1 }]); + }); +}); + +describe('crStarvation', () => { + it('sums open-days by author over CR-incomplete PRs, tracking the worst', () => { + const pulls = [ + open({ author: 'alice', status: 'needs_cr', ageDays: 10, n: 1 }), + open({ author: 'alice', status: 'needs_recr', ageDays: 4, n: 2 }), + open({ author: 'bob', status: 'needs_cr', ageDays: 20, n: 3 }), + open({ author: 'carol', status: 'ready', ageDays: 99, n: 4 }), // not starving + ]; + const s = crStarvation(pulls); + expect(s.map(x => x.login)).toEqual(['bob', 'alice']); // bob 20 > alice 14 + const alice = s.find(x => x.login === 'alice')!; + expect(alice).toMatchObject({ count: 2, totalDays: 14, worstDays: 10 }); + expect(s.find(x => x.login === 'carol')).toBeUndefined(); + }); +}); + +describe('mergeTimeBySize', () => { + it('buckets merged PRs by weight and reports count and medians, skipping unsized', () => { + const closed = [ + merged({ n: 1, add: 10, hours: 2 }), // XS + merged({ n: 2, add: 20, hours: 4 }), // XS + merged({ n: 3, add: 800, hours: 50 }), // L + merged({ n: 4, add: null, hours: 5 }), // no size, dropped + ]; + const r = mergeTimeBySize(closed); + expect(r.merged).toBe(4); + expect(r.sampled).toBe(3); + const xs = r.buckets.find(b => b.weight === 'XS')!; + expect(xs.count).toBe(2); + expect(xs.medianHours).toBe(3); // (2+4)/2 + expect(r.buckets.find(b => b.weight === 'L')!.count).toBe(1); + expect(r.buckets.find(b => b.weight === 'M')!.count).toBe(0); + }); + + it('ignores non-merged and non-positive durations', () => { + const closed = [ + merged({ n: 1, add: 10, hours: 0 }), // zero duration, dropped + { ...merged({ n: 2, add: 10, hours: 5 }), merged_at: null }, // not merged + ]; + expect(mergeTimeBySize(closed as unknown as PullData[]).sampled).toBe(0); + }); +}); + +describe('humanHours', () => { + it('formats minutes, hours, and days', () => { + expect(humanHours(0.5)).toBe('30m'); + expect(humanHours(3.2)).toBe('3.2h'); + expect(humanHours(50)).toBe('2.1d'); + }); +}); diff --git a/frontend-v2/src/model/stats.ts b/frontend-v2/src/model/stats.ts new file mode 100644 index 00000000..b1706e62 --- /dev/null +++ b/frontend-v2/src/model/stats.ts @@ -0,0 +1,154 @@ +import type { PullData } from '../types'; +import { type DerivedPull, type Status, type Weight, STATUS_ORDER, reviewWeight } from './status'; + +/** + * The Stats lens's aggregation layer: pure reductions over the same open pool + * and 14-day closed window the board already has. Everything here is a + * snapshot of what's on the board now, not an all-time record — the server + * only ships open pulls plus a fortnight of closed ones, so the leaderboards + * read "who's carrying review load right now", not "career totals". + */ + +export interface StatusCount { + status: Status; + count: number; +} + +/** open pulls per status, in board order, nonzero only. */ +export function statusBreakdown(pulls: DerivedPull[]): StatusCount[] { + const by = new Map(); + for (const p of pulls) by.set(p.status, (by.get(p.status) ?? 0) + 1); + return STATUS_ORDER.filter(s => by.get(s)).map(s => ({ status: s, count: by.get(s)! })); +} + +export interface Leader { + login: string; + /** distinct PRs this person has a sign-off of this type on */ + count: number; +} + +/** + * Who has signed off the most PRs, CR or QA. Counts distinct PRs (a re-stamp + * on the same pull is still one PR reviewed), across both active and + * push-invalidated stamps — an invalidated CR was still a review done. + */ +export function signoffLeaders( + pulls: DerivedPull[], + closed: PullData[], + type: 'CR' | 'QA' +): Leader[] { + // login -> set of repo#number they signed + const prs = new Map>(); + const add = (d: PullData) => { + const sigs = type === 'CR' ? d.status.allCR : d.status.allQA; + for (const s of sigs) { + const login = s.data.user.login; + const key = `${d.repo}#${d.number}`; + if (!prs.has(login)) prs.set(login, new Set()); + prs.get(login)!.add(key); + } + }; + for (const p of pulls) add(p.data); + for (const d of closed) add(d); + return [...prs.entries()] + .map(([login, set]) => ({ login, count: set.size })) + .sort((a, b) => b.count - a.count || a.login.localeCompare(b.login)); +} + +export interface Starved { + login: string; + /** their open PRs still short of full CR */ + count: number; + /** summed open-days across those PRs (the waiting they're carrying) */ + totalDays: number; + /** the single longest-waiting one */ + worstDays: number; +} + +/** + * Whose authored PRs are waiting longest for CR. A pull counts while it's + * CR-incomplete (needs_cr or needs_recr); the author is ranked by the total + * open-days their unreviewed PRs have piled up, with the worst single wait + * alongside so one ancient PR and ten fresh ones read differently. + */ +export function crStarvation(pulls: DerivedPull[]): Starved[] { + const by = new Map(); + for (const p of pulls) { + if (!['needs_cr', 'needs_recr'].includes(p.status)) continue; + const login = p.data.user.login; + const cur = by.get(login) ?? { count: 0, totalDays: 0, worstDays: 0 }; + cur.count += 1; + cur.totalDays += p.ageDays; + cur.worstDays = Math.max(cur.worstDays, p.ageDays); + by.set(login, cur); + } + return [...by.entries()] + .map(([login, v]) => ({ login, ...v })) + .sort((a, b) => b.totalDays - a.totalDays || b.worstDays - a.worstDays); +} + +export interface MergeBucket { + weight: Weight; + count: number; + avgHours: number; + medianHours: number; +} + +export interface MergeBySize { + /** one entry per weight class, in XS→XL order (count 0 kept, for scale) */ + buckets: MergeBucket[]; + /** merged pulls that had usable size + time data (what fed the buckets) */ + sampled: number; + /** merged pulls seen in the window (some may lack size, hence be dropped) */ + merged: number; +} + +const WEIGHTS: Weight[] = ['XS', 'S', 'M', 'L', 'XL']; + +/** + * How long a merge takes by diff size, over the closed window. Merge time is + * merged_at − created_at; a pull only counts if it merged, carries a size + * (additions/deletions on the wire), and has a sane positive duration. Both + * mean and median, because a couple of week-old outliers skew the mean and + * the median is the honest "typical". + */ +export function mergeTimeBySize(closed: PullData[]): MergeBySize { + const hours = new Map(WEIGHTS.map(w => [w, []])); + let sampled = 0; + let merged = 0; + for (const d of closed) { + if (!d.merged_at) continue; + merged += 1; + const sizeKnown = d.additions != null || d.deletions != null; + if (!sizeKnown) continue; + const h = (Date.parse(d.merged_at) - Date.parse(d.created_at)) / 3_600_000; + if (!(h > 0)) continue; + hours.get(reviewWeight(d))!.push(h); + sampled += 1; + } + const buckets = WEIGHTS.map(weight => { + const xs = hours.get(weight)!; + return { + weight, + count: xs.length, + avgHours: xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0, + medianHours: median(xs), + }; + }); + return { buckets, sampled, merged }; +} + +function median(xs: number[]): number { + if (!xs.length) return 0; + const s = [...xs].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; +} + +/** "3h" / "1.4d" / "12d" — durations for the merge-time bars. */ +export function humanHours(h: number): string { + if (h < 1) return `${Math.round(h * 60)}m`; + if (h < 24) return `${h < 10 ? h.toFixed(1) : Math.round(h)}h`; + const d = h / 24; + return `${d < 10 ? d.toFixed(1) : Math.round(d)}d`; +} diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts new file mode 100644 index 00000000..a4d154d1 --- /dev/null +++ b/frontend-v2/src/settings.ts @@ -0,0 +1,70 @@ +import { useSyncExternalStore } from 'react'; +import { ROT_DAYS, STARVE_DAYS } from './model/status'; +import { readStorage, writeStorage } from './storage'; + +/** + * User settings: the knobs that are a matter of personal taste, not team + * policy (that's config.json) or model correctness (that's the sort and + * starvation math). Persisted per-browser, same as scope. New fields fall + * back to their default so an old saved blob never breaks. + */ +export interface Settings { + /** 'system' follows the OS; the others pin it */ + theme: 'system' | 'light' | 'dark'; + /** row height: comfortable is the default, compact packs more on screen */ + density: 'comfortable' | 'compact'; + /** which lens a bare /v2/ URL opens */ + defaultLens: string; + /** a PR's age turns amber at this many days (display only) */ + ageWarnDays: number; + /** and red at this many (display only) */ + ageRotDays: number; + /** seconds of attention before leaving stamps "last seen" (the glance guard) */ + seenAfterSecs: number; +} + +export const DEFAULT_SETTINGS: Settings = { + theme: 'system', + density: 'comfortable', + defaultLens: 'review', + ageWarnDays: STARVE_DAYS, + ageRotDays: ROT_DAYS, + seenAfterSecs: 45, +}; + +const KEY = 'pd2.settings'; + +function load(): Settings { + try { + return { + ...DEFAULT_SETTINGS, + ...(JSON.parse(readStorage(KEY) ?? '{}') as Partial), + }; + } catch { + return { ...DEFAULT_SETTINGS }; + } +} + +let settings = load(); +const listeners = new Set<() => void>(); + +/** Plain getter for non-React readers (the store's glance guard). */ +export function getSettings(): Settings { + return settings; +} + +export function setSettings(patch: Partial) { + settings = { ...settings, ...patch }; + writeStorage(KEY, JSON.stringify(settings)); + for (const fn of listeners) fn(); +} + +export function useSettings(): Settings { + return useSyncExternalStore( + fn => { + listeners.add(fn); + return () => listeners.delete(fn); + }, + () => settings + ); +} diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts index 1d08d0ed..582e3c95 100644 --- a/frontend-v2/src/store.ts +++ b/frontend-v2/src/store.ts @@ -1,6 +1,7 @@ import { useSyncExternalStore } from 'react'; import { backend, type ConnectionState } from './backend/socket'; import { derive, type DerivedPull } from './model/status'; +import { getSettings } from './settings'; import { readStorage, writeStorage } from './storage'; import type { PullData, RepoSpec } from './types'; @@ -44,9 +45,9 @@ const listeners = new Set<() => void>(); // arrive — an accidental reload must not erase "changed since yesterday". // And only after the page has actually been LOOKED AT: a two-second Monday // glance on the way to Slack must not mark the weekend's forty changes as -// seen. "Looked at" = 45 cumulative visible seconds since the last stamp. -const MIN_ATTENDED_SECS = 45; -const lastSeen = Number(readStorage(LAST_SEEN_KEY)) || Date.now() / 1000 - 6 * 3600; +// seen. "Looked at" = N cumulative visible seconds since the last stamp, +// where N is the user's glance-guard setting (seenAfterSecs). +let lastSeen = Number(readStorage(LAST_SEEN_KEY)) || Date.now() / 1000 - 6 * 3600; let attendedSecs = 0; let visibleSince: number | null = document.visibilityState === 'visible' ? Date.now() / 1000 : null; const settleAttention = () => { @@ -57,8 +58,9 @@ const settleAttention = () => { }; const stampSeen = () => { settleAttention(); - if (attendedSecs < MIN_ATTENDED_SECS) return; - writeStorage(LAST_SEEN_KEY, String(Date.now() / 1000)); + if (attendedSecs < getSettings().seenAfterSecs) return; + lastSeen = Date.now() / 1000; + writeStorage(LAST_SEEN_KEY, String(lastSeen)); attendedSecs = 0; }; window.addEventListener('pagehide', stampSeen); @@ -67,6 +69,14 @@ document.addEventListener('visibilitychange', () => { else visibleSince = Date.now() / 1000; }); +/** Settings action: treat everything on the board as seen, right now. */ +export function markAllSeen() { + lastSeen = Date.now() / 1000; + writeStorage(LAST_SEEN_KEY, String(lastSeen)); + attendedSecs = 0; + schedulePublish(); +} + // Per-row acknowledgment: opening a PR clears its fresh dot for this session // without waiting for departure to stamp the whole board. const acked = new Set(); diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index e6917edf..33dbac6c 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -265,6 +265,30 @@ a:hover { background: transparent; } +/* compact density: pack more rows on screen by tightening the row gutter */ +:root[data-density="compact"] .pd-row { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +/* settings slide-out from the right, with a fading scrim */ +@keyframes panel-in { + from { + translate: 100% 0; + } +} +@keyframes scrim-in { + from { + opacity: 0; + } +} +.settings-panel { + animation: panel-in 220ms var(--ease-quint); +} +.settings-scrim { + animation: scrim-in 160ms ease-out; +} + /* press feedback: house scale-97 */ .pressable { transition: diff --git a/frontend-v2/src/views/Stats.tsx b/frontend-v2/src/views/Stats.tsx new file mode 100644 index 00000000..5ad5940a --- /dev/null +++ b/frontend-v2/src/views/Stats.tsx @@ -0,0 +1,82 @@ +import { useMemo } from 'react'; +import type { DerivedPull } from '../model/status'; +import { crStarvation, mergeTimeBySize, signoffLeaders, statusBreakdown } from '../model/stats'; +import { useSettings } from '../settings'; +import type { PullData } from '../types'; +import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; +import { Leaderboard } from './stats/Leaderboard'; +import { MergeSizeCard } from './stats/MergeSizeCard'; +import { StarvationCard } from './stats/StarvationCard'; +import { StatusBar } from './stats/StatusBar'; + +/** + * The Stats lens: the board's shape and its review economics at a glance. + * Everything is a snapshot of the current pool (open PRs plus the 14-day + * closed window the server keeps), so it moves with the scope and filter — + * scope to a team and these become that team's numbers. + */ +export function Stats({ + pulls, + closed, + me, + onPerson, +}: { + pulls: DerivedPull[]; + closed: PullData[]; + me: string; + onPerson: (login: string) => void; +}) { + const breakdown = useMemo( + () => + statusBreakdown(pulls).map(s => ({ + status: s.status, + count: s.count, + label: STATUS_LABEL[s.status], + color: STATUS_DOT[s.status], + })), + [pulls] + ); + const crLeaders = useMemo(() => signoffLeaders(pulls, closed, 'CR'), [pulls, closed]); + const qaLeaders = useMemo(() => signoffLeaders(pulls, closed, 'QA'), [pulls, closed]); + const starved = useMemo(() => crStarvation(pulls), [pulls]); + const merge = useMemo(() => mergeTimeBySize(closed), [closed]); + const settings = useSettings(); + + if (!pulls.length && !closed.length) { + return ( + + ); + } + + return ( +
+ +
+ + + + +
+
+ ); +} diff --git a/frontend-v2/src/views/stats/Leaderboard.tsx b/frontend-v2/src/views/stats/Leaderboard.tsx new file mode 100644 index 00000000..cd5d264e --- /dev/null +++ b/frontend-v2/src/views/stats/Leaderboard.tsx @@ -0,0 +1,68 @@ +import { Avatar } from '../../components/bits'; + +export function Leaderboard({ + title, + sub, + accent, + rows, + me, + onPerson, + cap = 12, +}: { + title: string; + sub?: string; + /** CSS var for the bar fill, e.g. 'var(--brand)' for CR, 'var(--violet)' for QA */ + accent: string; + rows: { login: string; count: number }[]; + me?: string; + onPerson?: (login: string) => void; + cap?: number; +}) { + const shown = rows.slice(0, cap); + const max = rows[0]?.count ?? 0; + const more = rows.length - shown.length; + + return ( +
+

+ {title} + {sub && {sub}} +

+ {shown.length === 0 ? ( +
No sign-offs on the board yet.
+ ) : ( +
+ {shown.map((row, i) => { + const pct = max > 0 ? (row.count / max) * 100 : 0; + return ( +
+ + {i + 1} + + + + {row.login === me ? ( + {row.login} + ) : ( + row.login + )} + {row.login === me && you} + +
+
+
+ + {row.count} + +
+ ); + })} + {more > 0 &&
+{more} more reviewers
} +
+ )} +
+ ); +} diff --git a/frontend-v2/src/views/stats/MergeSizeCard.tsx b/frontend-v2/src/views/stats/MergeSizeCard.tsx new file mode 100644 index 00000000..15f2c48b --- /dev/null +++ b/frontend-v2/src/views/stats/MergeSizeCard.tsx @@ -0,0 +1,82 @@ +import type { Weight } from '../../model/status'; +import { humanHours, type MergeBucket } from '../../model/stats'; + +const RAMP: Record = { + XS: 'var(--ok)', + S: 'var(--ok)', + M: 'var(--ink-3)', + L: 'var(--warn)', + XL: 'var(--bad)', +}; + +export function MergeSizeCard({ + buckets, + sampled, + merged, +}: { + /** exactly 5 entries, already in XS→XL order; count 0 kept for scale */ + buckets: MergeBucket[]; + /** merged PRs that had usable size+time data (fed the buckets) */ + sampled: number; + /** merged PRs seen in the window total (some dropped for missing size) */ + merged: number; +}) { + const max = Math.max(...buckets.map(b => b.medianHours)); + + return ( +
+

+ Time to merge by size + median, last 14 days +

+ {sampled === 0 ? ( +
+ No merged PRs with size data in this window. + {merged > 0 && ( +
+ {merged} merged, but no diff sizes on the wire +
+ )} +
+ ) : ( + <> +
+ {buckets.map(b => { + const pct = b.count > 0 && max > 0 ? (b.medianHours / max) * 100 : 0; + return ( +
+ {b.weight} + {b.count > 0 ? ( +
+
+
+ ) : ( +
no data
+ )} + {b.count > 0 && ( + + {humanHours(b.medianHours)} + · {b.count} + + )} +
+ ); + })} +
+
+ {merged > sampled + ? `${sampled} of ${merged} merged PRs had a size` + : `${sampled} merged PRs`} +
+ + )} +
+ ); +} diff --git a/frontend-v2/src/views/stats/StarvationCard.tsx b/frontend-v2/src/views/stats/StarvationCard.tsx new file mode 100644 index 00000000..db22fa46 --- /dev/null +++ b/frontend-v2/src/views/stats/StarvationCard.tsx @@ -0,0 +1,70 @@ +import { Avatar } from '../../components/bits'; + +export function StarvationCard({ + rows, + me, + onPerson, + warnDays, + rotDays, + cap = 10, +}: { + rows: { login: string; count: number; totalDays: number; worstDays: number }[]; // sorted desc by totalDays + me?: string; + onPerson?: (login: string) => void; + /** worstDays >= warnDays => amber heat; >= rotDays => red heat */ + warnDays: number; + rotDays: number; + cap?: number; +}) { + const shown = rows.slice(0, cap); + const max = rows[0]?.totalDays ?? 0; + + return ( +
+

+ Waiting longest for CR + + by author · total open-days their PRs sit unreviewed + +

+ {shown.length === 0 ? ( +
No PRs are starving for CR right now.
+ ) : ( +
+ {shown.map(row => { + const pct = max > 0 ? (row.totalDays / max) * 100 : 0; + const heat = + row.worstDays >= rotDays + ? 'var(--bad)' + : row.worstDays >= warnDays + ? 'var(--warn)' + : 'var(--ink-3)'; + return ( +
+ + + {row.login === me ? ( + {row.login} + ) : ( + row.login + )} + {row.login === me && you} + +
+
+
+ + {row.count} {row.count === 1 ? 'PR' : 'PRs'} · worst{' '} + {row.worstDays}d + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend-v2/src/views/stats/StatusBar.tsx b/frontend-v2/src/views/stats/StatusBar.tsx new file mode 100644 index 00000000..c939fb1d --- /dev/null +++ b/frontend-v2/src/views/stats/StatusBar.tsx @@ -0,0 +1,49 @@ +import type { Status } from '../../model/status'; + +/** + * The open-PR breakdown, moved out of the header and given room to breathe: a + * headline count, a single stacked bar of the mix, and a legend of every + * status with its share. This is the board's shape at a glance — where the + * work is piled up right now. + */ +export function StatusBar({ + items, + total, +}: { + items: { status: Status; count: number; label: string; color: string }[]; + total: number; +}) { + return ( +
+
+ {total} + open {total === 1 ? 'PR' : 'PRs'} +
+ {total > 0 && ( + <> +
+ {items.map(it => ( +
+ ))} +
+
+ {items.map(it => ( + + + {it.label} + {it.count} + + ))} +
+ + )} +
+ ); +} From d0a1a06ff9cf64cb969c45ae12febf9e72963566 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 13:40:39 -0700 Subject: [PATCH 045/381] Unify scope, hidden, mutes, and drafts into one Filters control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board had three visibility mechanisms with three mental models that never reconciled: Scope (inclusive "only these"), the eye/Hidden selector (reveal the off-by-default groups), and — newly asked for — per-user repo mutes (exclusive "never these"), plus a drafts rule that only existed in the legacy path. Four ways to answer one question ("what's on my board?"). Collapse them to one model: your board is every open PR, minus what's muted, narrowed to what's in scope, and a session act can override either for now. There's one exclude concept (mute, sourced from the org's hideByDefault baseline and cryo, plus your durable per-repo mutes) and one include concept (scope); "reveal" stops being a third polarity and becomes session un-muting. Three layers vote in a fixed order — session-explicit beats durable-user beats org-baseline — so scoping or `repo:`-querying a muted repo surfaces it without un-muting it (the precedence you picked). src/model/visibility.ts owns a repo's baseline state (shown / muted / org-hidden). settings.ts gains repoPrefs (per-repo mute/show override), draftsMode (mine = hide others' drafts, your own always show; all), and a cryo default. The inScope pipeline gets two new predicates (mute-with-reveal, drafts) and drafts rides the URL as an override of your default. The UI consolidates into one Filters control whose trigger is the active-filter summary ("All repos" / "1 muted · all drafts") — the standing answer to what's hidden, which used to be scattered across popovers. Its tabs: Repos (include checkbox + mute per row; muted/org-hidden collapsed with a session reveal and a cryo toggle), People (team presets + searchable authors), Drafts (Mine/All). The durable editor is a new Repo Manager group in Settings. The standalone Scope popover and eye/HiddenSelector are deleted; the text query and the changed chip stay separate, as different axes. Co-Authored-By: Claude Fable 5 --- frontend-v2/README.md | 34 +- frontend-v2/src/app.tsx | 140 +++++-- frontend-v2/src/components/Filters.tsx | 371 ++++++++++++++++++ frontend-v2/src/components/HiddenSelector.tsx | 133 ------- frontend-v2/src/components/RepoManager.tsx | 128 ++++++ frontend-v2/src/components/Scope.tsx | 202 ---------- frontend-v2/src/components/Settings.tsx | 44 ++- frontend-v2/src/model/visibility.ts | 39 ++ frontend-v2/src/settings.ts | 19 + 9 files changed, 719 insertions(+), 391 deletions(-) create mode 100644 frontend-v2/src/components/Filters.tsx delete mode 100644 frontend-v2/src/components/HiddenSelector.tsx create mode 100644 frontend-v2/src/components/RepoManager.tsx delete mode 100644 frontend-v2/src/components/Scope.tsx create mode 100644 frontend-v2/src/model/visibility.ts diff --git a/frontend-v2/README.md b/frontend-v2/README.md index 3200dfb1..158dfe17 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -47,10 +47,9 @@ serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol 10, with both clocks in the tooltip. "Iterating" keys on the push clock, not updated_at, so a reviewer's comment can't sink a pull down the queue. - **Five lenses, one filter.** Review / My work / People (person or team - drill-down) / Classic / Stats, over the same pool, with a single saved - Scope (repos + people, with GitHub-team presets). The whole view — lens, + drill-down) / Classic / Stats, over the same pool. The whole view — lens, query, scope, toggles — lives in the URL hash: any board is pasteable and a - bookmark is a saved view. The filter understands `#number`, `label:x`, + bookmark is a saved view. The text filter understands `#number`, `label:x`, `status:x`, `older:5`, `repo:x`, and `author:x`. - **The Stats lens.** The board's shape and its review economics: the open breakdown as a stacked bar, CR and QA leaderboards (distinct PRs signed off, @@ -61,15 +60,26 @@ serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol - **Settings.** A cog opens a right-side panel for the knobs that are personal taste, not team policy or model math: theme (system/light/dark), row density, default view, the age-color thresholds (amber/red days, display - only), and the glance guard — how many attended seconds count as a look, - plus a one-click "mark everything as seen". Persisted per-browser in - `src/settings.ts`. -- **An eye, not a snowflake.** Two kinds of PR hide by default — - Cryogenic-Storage (deliberately parked) and hide-by-default repos (quiet - noise). The visibility control is a selector, not an all-or-nothing toggle: - reveal everything, or one group at a time (`#show=cryo,someRepo`). Hidden - repos are listed and marked in the Scope control too, so you can scope one - onto the board directly. + only), the glance guard (how many attended seconds count as a look) plus a + one-click "mark everything as seen", your drafts and cryo defaults, and the + Repo Manager (mute/unmute repos, unhide org-hidden ones). Persisted + per-browser in `src/settings.ts`. Org-level repo hides come from the + server's `hideByDefault` repo spec — the read-only baseline you override. +- **One visibility model: muted vs in-scope, override for now.** Scope + (include) and mute (exclude) aren't rival mechanisms — scope is "which + slice am I focused on", mute is "repos I never want to see", and a session + act (a scope, a `repo:` term, a reveal) overrides a mute for right now. + Three parties vote in a fixed order — org baseline (`hideByDefault` + cryo), + your durable prefs (muted repos, drafts default), then the session (URL) — + with session-explicit beating a durable mute. It all lives behind one + **Filters** control whose trigger is the active-filter summary (the standing + answer to "what's hidden right now"): tabs for Repos (include checkbox + + mute icon per row, muted/org-hidden collapsed with a reveal), People (teams + + authors), and Drafts (Mine/All — your own drafts always show). The durable + editor is the Settings **Repo Manager** (muted / org-hidden / on-your-board). + `src/model/visibility.ts` decides a repo's baseline state; the old separate + Scope popover and eye/Hidden selector folded into this. Session reveal still + rides the URL as `#show=cryo,someRepo`; `#drafts=all` overrides your default. - **Classic is v1, faithfully.** The same six overlapping columns (CI Blocked / Deploy Blocked / Ready / Dev Block / CR / QA), predicates and sorts ported line-for-line from v1, for anyone whose muscle memory diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 1ba878e9..72f5cdfb 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -7,9 +7,9 @@ import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy import { loadSiteConfig, primeScope, useScope } from './prefs'; import { getSettings, useSettings } from './settings'; import { matchesQuery } from './model/query'; +import { CRYO_KEY, repoHidden } from './model/visibility'; import { Legend } from './components/Legend'; -import { CRYO_KEY, HiddenSelector } from './components/HiddenSelector'; -import { ScopeControl } from './components/Scope'; +import { Filters } from './components/Filters'; import type { RowOptions } from './components/Row'; import { Review } from './views/Review'; import { MyWork } from './views/MyWork'; @@ -39,6 +39,8 @@ interface HashState { hidden: boolean; /** individually revealed hidden groups: repo names and the cryo sentinel */ reveal: string[]; + /** session override of the drafts default (null = use the durable default) */ + drafts: 'mine' | 'all' | null; changed: boolean; } @@ -60,6 +62,7 @@ function readHash(): HashState { authors: p.get('authors')?.split(',').filter(Boolean) ?? [], hidden: p.get('hidden') === '1', reveal: p.get('show')?.split(',').filter(Boolean) ?? [], + drafts: p.get('drafts') === 'all' ? 'all' : p.get('drafts') === 'mine' ? 'mine' : null, changed: p.get('changed') === '1', }; } @@ -74,6 +77,7 @@ function buildHash(s: HashState): string { if (s.authors.length) p.set('authors', s.authors.join(',')); if (s.hidden) p.set('hidden', '1'); if (s.reveal.length) p.set('show', s.reveal.join(',')); + if (s.drafts) p.set('drafts', s.drafts); if (s.changed) p.set('changed', '1'); return p.toString(); } @@ -176,6 +180,10 @@ export function App() { setReveal(cur => (cur.includes(key) ? cur.filter(k => k !== key) : [...cur, key])), [] ); + // drafts is a session override of the durable default; null-in-URL uses it + const [draftsMode, setDraftsMode] = useState<'mine' | 'all'>( + () => urlState.drafts ?? getSettings().draftsMode + ); const [teams, setTeams] = useState([]); const [extraBots, setExtraBots] = useState>(new Set()); const isBot = useCallback( @@ -211,6 +219,7 @@ export function App() { authors: scope.authors, hidden: showAll, reveal, + drafts: draftsMode !== settings.draftsMode ? draftsMode : null, changed: onlyChanged, }); if (next === location.hash.slice(1)) return; @@ -222,7 +231,18 @@ export function App() { } else { history.replaceState(null, '', next ? `#${next}` : location.pathname + location.search); } - }, [lens, person, team, query, scope, showAll, reveal, onlyChanged]); + }, [ + lens, + person, + team, + query, + scope, + showAll, + reveal, + draftsMode, + settings.draftsMode, + onlyChanged, + ]); useEffect(() => { const onHash = () => { const h = readHash(); @@ -232,6 +252,7 @@ export function App() { setQuery(h.q); setShowAll(h.hidden); setReveal(h.reveal); + setDraftsMode(h.drafts ?? getSettings().draftsMode); setOnlyChanged(h.changed); }; window.addEventListener('hashchange', onHash); @@ -307,31 +328,61 @@ export function App() { // scope/query applied, but NOT the changed-only toggle: the changed count // must describe the pool the toggle would narrow, or the banner promises // rows the click doesn't deliver + // repos named in the query (repo:x) reveal a muted repo for this session — + // an explicit filter is an explicit "I want it now" + const queryRepos = useMemo( + () => (query ? [...query.matchAll(/repo:(\S+)/gi)].map(m => m[1].toLowerCase()) : []), + [query] + ); + const revealedRepo = useCallback( + (repo: string) => + reveal.includes(repo) || + scope.repos.includes(repo) || + !!legacy?.repos.includes(shortRepo(repo)) || + queryRepos.some(q => shortRepo(repo).toLowerCase().includes(q)), + [reveal, scope.repos, legacy, queryRepos] + ); + const inScope = useMemo(() => { let out = pulls; if (legacy) out = applyLegacyFilters(out, legacy, me); - // v1 conventions: Cryogenic-Storage pulls and hideByDefault repos stay - // off the board unless revealed (all at once, per-group, or by scoping - // the repo). + // Muted (user) and org-hidden repos, and cryo PRs, stay off the board + // unless a session act reveals them: an explicit reveal, a scope, or a + // repo: query term. Session-explicit beats the durable mute. if (!showAll) out = out.filter(p => { - const cryoHidden = p.cryo && !reveal.includes(CRYO_KEY); - const repoHidden = - hiddenRepos.has(p.data.repo) && - !reveal.includes(p.data.repo) && - !scope.repos.includes(p.data.repo) && - // a legacy URL naming the repo means "show it", hidden or not - !legacy?.repos.includes(shortRepo(p.data.repo)); - return !cryoHidden && !repoHidden; + const hiddenRepo = + repoHidden(p.data.repo, hiddenRepos, settings.repoPrefs) && + !revealedRepo(p.data.repo); + const cryoHidden = p.cryo && !settings.showCryo && !reveal.includes(CRYO_KEY); + return !hiddenRepo && !cryoHidden; }); if (scope.repos.length) out = out.filter(p => scope.repos.includes(p.data.repo)); // bots bypass the people filter on purpose: dependency bumps need review // no matter whose work you follow (they land in the bots fold, not lanes) if (scope.authors.length) out = out.filter(p => isBot(p) || scope.authors.includes(p.data.user.login)); + // drafts: 'mine' hides other people's drafts, your own always show. + // The legacy path owns its own draft rule, so don't double-apply. + if (!legacy && draftsMode === 'mine') + out = out.filter(p => !p.data.draft || p.data.user.login === me); if (query) out = out.filter(p => matchesQuery(p, query)); return out; - }, [pulls, scope, query, showAll, reveal, hiddenRepos, legacy, me, isBot]); + }, [ + pulls, + scope, + query, + showAll, + reveal, + revealedRepo, + hiddenRepos, + legacy, + me, + isBot, + settings.repoPrefs, + settings.showCryo, + draftsMode, + ]); // changed-only and the banner count share one predicate (bots excluded, // acked rows drop out) so the toggle always shows exactly what the banner @@ -350,25 +401,28 @@ export function App() { const mergedCount = closed.filter( p => (Date.parse(p.closed_at ?? '') / 1000 || 0) > lastSeen ).length; - // the two hidden groups, and how many PRs each holds, for the selector. - // hiddenCount is how many are hidden *right now* given the reveal state. - const cryoCount = pulls.filter(p => p.cryo).length; - const hiddenRepoCounts = useMemo(() => { + // every known repo with its open-PR count — feeds the Filters popover and + // the Settings repo manager. Includes org-hidden and pref'd repos at 0. + const repoCounts = useMemo(() => { const m = new Map(); for (const name of hiddenRepos) m.set(name, 0); - for (const p of pulls) - if (hiddenRepos.has(p.data.repo)) m.set(p.data.repo, (m.get(p.data.repo) ?? 0) + 1); - return [...m.entries()].sort((a, b) => b[1] - a[1]); - }, [pulls, hiddenRepos]); + for (const name of Object.keys(settings.repoPrefs)) if (!m.has(name)) m.set(name, 0); + for (const p of pulls) m.set(p.data.repo, (m.get(p.data.repo) ?? 0) + 1); + return [...m.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); + }, [pulls, hiddenRepos, settings.repoPrefs]); + + const cryoCount = pulls.filter(p => p.cryo).length; + // how many PRs are hidden *right now*, given mutes/org-hides and reveals const hiddenCount = showAll ? 0 : pulls.filter(p => { - const cryoHidden = p.cryo && !reveal.includes(CRYO_KEY); - const repoHidden = - hiddenRepos.has(p.data.repo) && - !reveal.includes(p.data.repo) && - !scope.repos.includes(p.data.repo); - return cryoHidden || repoHidden; + const hiddenRepo = + repoHidden(p.data.repo, hiddenRepos, settings.repoPrefs) && + !revealedRepo(p.data.repo); + const cryoHidden = p.cryo && !settings.showCryo && !reveal.includes(CRYO_KEY); + return hiddenRepo || cryoHidden; }).length; const isScoped = scope.repos.length || scope.authors.length || query || onlyChanged; @@ -448,7 +502,7 @@ export function App() { > v1 board - +
- + ✕ )} - {(cryoCount > 0 || hiddenRepoCounts.length > 0) && ( - - )} {bots.length > 0 && ( diff --git a/frontend-v2/src/components/Filters.tsx b/frontend-v2/src/components/Filters.tsx new file mode 100644 index 00000000..d014b77d --- /dev/null +++ b/frontend-v2/src/components/Filters.tsx @@ -0,0 +1,371 @@ +import { useState } from 'react'; +import type { DerivedPull } from '../model/status'; +import { CRYO_KEY, repoState } from '../model/visibility'; +import { shortRepo } from '../format'; +import { useScope, type Scope } from '../prefs'; +import { setRepoPref, useSettings } from '../settings'; +import type { Team } from '../types'; +import { Avatar } from './bits'; +import { usePopover } from './usePopover'; + +type Tab = 'repos' | 'people' | 'drafts'; + +/** + * The one visibility surface: what's on my board and why. Its trigger is the + * active-filter summary (the standing answer to "what's hidden right now"), + * and its popover consolidates the old Scope popover and the eye/Hidden + * selector into tabs. Include (scope) and exclude (mute) live side by side on + * each repo row; reveal is just un-muting for this session. + */ +export function Filters({ + pulls, + repos, + teams, + orgHidden, + reveal, + toggleReveal, + showAll, + setShowAll, + cryoCount, + draftsMode, + setDraftsMode, + hiddenCount, +}: { + pulls: DerivedPull[]; + /** all repos with open-PR counts (org-hidden and muted included) */ + repos: { name: string; count: number }[]; + teams: Team[]; + orgHidden: ReadonlySet; + reveal: string[]; + toggleReveal: (key: string) => void; + showAll: boolean; + setShowAll: (next: boolean) => void; + cryoCount: number; + draftsMode: 'mine' | 'all'; + setDraftsMode: (m: 'mine' | 'all') => void; + hiddenCount: number; +}) { + const pop = usePopover(); + const [scope, setScope] = useScope(); + const settings = useSettings(); + const prefs = settings.repoPrefs; + const [tab, setTab] = useState('repos'); + const [repoQuery, setRepoQuery] = useState(''); + const [peopleQuery, setPeopleQuery] = useState(''); + + const authorCounts = new Map(); + for (const p of pulls) + authorCounts.set(p.data.user.login, (authorCounts.get(p.data.user.login) ?? 0) + 1); + for (const name of scope.authors) if (!authorCounts.has(name)) authorCounts.set(name, 0); + const authors = [...authorCounts.entries()].sort( + (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]) + ); + + const shownRepos = repos.filter(r => repoState(r.name, orgHidden, prefs) === 'shown'); + const hiddenRepos = repos.filter(r => repoState(r.name, orgHidden, prefs) !== 'shown'); + const mutedCount = repos.filter(r => repoState(r.name, orgHidden, prefs) === 'muted').length; + + const commit = (key: keyof Scope, next: string[], all: string[]) => + setScope({ ...scope, [key]: all.length && next.length === all.length ? [] : next }); + const toggle = (key: keyof Scope, name: string, all: string[]) => { + const cur = scope[key].length ? [...scope[key]] : [...all]; + const i = cur.indexOf(name); + if (i >= 0) cur.splice(i, 1); + else cur.push(name); + commit(key, cur, all); + }; + const included = (key: keyof Scope, name: string) => + !scope[key].length || scope[key].includes(name); + + // the trigger summary: every active narrowing, or "All repos" + const bits: string[] = []; + if (scope.repos.length) + bits.push(`${scope.repos.length} repo${scope.repos.length > 1 ? 's' : ''}`); + if (scope.authors.length) + bits.push(`${scope.authors.length} ${scope.authors.length > 1 ? 'people' : 'person'}`); + if (mutedCount) bits.push(`${mutedCount} muted`); + if (draftsMode === 'all') bits.push('all drafts'); + if (showAll) bits.push('all hidden shown'); + else if (reveal.length) bits.push(`${reveal.length} revealed`); + const active = bits.length > 0 || scope.repos.length > 0 || scope.authors.length > 0; + const summary = bits.length ? bits.join(' · ') : 'All repos'; + + const reset = () => { + setScope({ repos: [], authors: [] }); + setShowAll(false); + setDraftsMode(settings.draftsMode); + for (const k of reveal) toggleReveal(k); + }; + + const tabBtn = (id: Tab, label: string) => ( + + ); + + const search = (value: string, set: (v: string) => void, label: string) => ( + set(e.target.value)} + aria-label={label} + placeholder={label} + className="mb-2 h-8 w-full rounded-lg border border-line bg-surface px-2.5 text-[13px]" + /> + ); + + const repoRow = (name: string, count: number, state: ReturnType) => ( +
+ {state === 'shown' ? ( + + toggle( + 'repos', + name, + shownRepos.map(r => r.name) + ) + } + aria-label={`scope to ${shortRepo(name)}`} + /> + ) : ( + toggleReveal(name)} + aria-label={`reveal ${shortRepo(name)} for now`} + /> + )} + + {shortRepo(name)} + {state === 'org-hidden' && org} + + {count || ''} + {state === 'muted' ? ( + + ) : state === 'org-hidden' ? ( + + ) : ( + + )} +
+ ); + + const filteredShown = shownRepos.filter(r => + shortRepo(r.name).toLowerCase().includes(repoQuery.toLowerCase()) + ); + const filteredHidden = hiddenRepos.filter(r => + shortRepo(r.name).toLowerCase().includes(repoQuery.toLowerCase()) + ); + + return ( + + + {(active || hiddenCount > 0) && ( + + )} + {pop.open && ( + +
+ {tabBtn('repos', 'Repos')} + {tabBtn('people', 'People')} + {tabBtn('drafts', 'Drafts')} +
+ + {tab === 'repos' && ( + <> + {search(repoQuery, setRepoQuery, 'Filter repos')} + {filteredShown.map(r => repoRow(r.name, r.count, 'shown'))} + {filteredShown.length === 0 && ( +
No repos match.
+ )} + {filteredHidden.length > 0 && ( +
+ + Muted & org-hidden ({filteredHidden.length}) + + + {filteredHidden.map(r => + repoRow(r.name, r.count, repoState(r.name, orgHidden, prefs)) + )} +
+ )} + {cryoCount > 0 && ( + + )} + + )} + + {tab === 'people' && ( + <> + {teams.length > 0 && ( +
+ {teams.map(t => ( + + ))} +
+ )} + {search(peopleQuery, setPeopleQuery, 'Filter people')} + {authors + .filter(([login]) => + login.toLowerCase().includes(peopleQuery.toLowerCase()) + ) + .map(([login, count]) => ( + + ))} + + )} + + {tab === 'drafts' && ( +
+
+ + +
+

+ {draftsMode === 'mine' + ? 'Other people’s drafts are hidden. Your own drafts always show.' + : 'Everyone’s drafts show.'} +

+
+ )} +
+ )} +
+ ); +} diff --git a/frontend-v2/src/components/HiddenSelector.tsx b/frontend-v2/src/components/HiddenSelector.tsx deleted file mode 100644 index 57bd605d..00000000 --- a/frontend-v2/src/components/HiddenSelector.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { shortRepo } from '../format'; -import { usePopover } from './usePopover'; - -/** Open eye = revealing, slashed eye = hidden. */ -function Eye({ off }: { off?: boolean }) { - return ( - - - {off && ( - - )} - - ); -} - -/** the sentinel key for the Cryogenic-Storage group in the reveal set */ -export const CRYO_KEY = 'cryo'; - -/** - * The visibility control: which off-by-default PRs to show. Two kinds hide by - * default — Cryogenic-Storage PRs (deliberately parked) and hide-by-default - * repos (low-traffic noise) — and this is where you reveal them, all at once - * or one group at a time, instead of the old all-or-nothing snowflake toggle. - */ -export function HiddenSelector({ - count, - cryo, - repos, - showAll, - reveal, - onShowAll, - onToggle, -}: { - /** how many PRs are hidden right now, given the current reveal state */ - count: number; - /** total Cryogenic-Storage PRs (0 hides the row) */ - cryo: number; - /** hide-by-default repos with their open-PR counts */ - repos: [string, number][]; - /** master: reveal everything hidden */ - showAll: boolean; - /** individually revealed keys (repo names and CRYO_KEY) */ - reveal: string[]; - onShowAll: (next: boolean) => void; - onToggle: (key: string) => void; -}) { - const pop = usePopover(); - const revealing = showAll || reveal.length > 0; - - const row = (key: string, label: string, n: number) => ( - - ); - - return ( - - - {pop.open && ( - - - Off-by-default PRs. Reveal what you want to see. - - - {cryo > 0 && ( - <> - - Cryogenic Storage - - {row(CRYO_KEY, 'parked PRs', cryo)} - - )} - {repos.length > 0 && ( - <> - - Quiet repos - - {repos.map(([name, n]) => row(name, shortRepo(name), n))} - - )} - - )} - - ); -} diff --git a/frontend-v2/src/components/RepoManager.tsx b/frontend-v2/src/components/RepoManager.tsx new file mode 100644 index 00000000..39dac4cf --- /dev/null +++ b/frontend-v2/src/components/RepoManager.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { repoState } from '../model/visibility'; + +export function RepoManagerGroup({ + repos, + orgHidden, + prefs, + onRepoPref, +}: { + /** every known repo with its open-PR count, sorted however the caller likes */ + repos: { name: string; count: number }[]; + /** repo names the org hides by default (read-only baseline) */ + orgHidden: ReadonlySet; + /** the user's per-repo overrides */ + prefs: Record; + /** set or clear one repo's override; null follows the org default */ + onRepoPref: (repo: string, pref: 'mute' | 'show' | null) => void; +}) { + const [query, setQuery] = useState(''); + + const needle = query.trim().toLowerCase(); + const filtered = + needle === '' ? repos : repos.filter(repo => repo.name.toLowerCase().includes(needle)); + + const muted = filtered.filter(repo => repoState(repo.name, orgHidden, prefs) === 'muted'); + const orgHiddenRepos = filtered.filter( + repo => repoState(repo.name, orgHidden, prefs) === 'org-hidden' + ); + const shown = filtered.filter(repo => repoState(repo.name, orgHidden, prefs) === 'shown'); + + const noMatches = filtered.length === 0; + + return ( +
+

+ Repos +

+ setQuery(event.target.value)} + placeholder="Filter repos" + type="text" + value={query} + /> + + {noMatches &&
No repos match.
} + + {muted.length > 0 && ( +
+
+ Muted by you ({muted.length}) +
+ {muted.map(repo => ( +
+ {repo.name} + {repo.count} + +
+ ))} +
+ )} + + {orgHiddenRepos.length > 0 && ( +
+
+ Hidden by the org ({orgHiddenRepos.length}) +
+ {orgHiddenRepos.map(repo => ( +
+ {repo.name} + {repo.count} + +
+ ))} +
+ )} + + {shown.length > 0 && ( +
+
+ On your board ({shown.length}) +
+ {shown.map(repo => { + const isUserRevealed = orgHidden.has(repo.name) && prefs[repo.name] === 'show'; + return ( +
+ {repo.name} + {isUserRevealed && ( + org-hidden · showing + )} + {repo.count} + {isUserRevealed && ( + + )} + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend-v2/src/components/Scope.tsx b/frontend-v2/src/components/Scope.tsx deleted file mode 100644 index bd0caa13..00000000 --- a/frontend-v2/src/components/Scope.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import type { DerivedPull } from '../model/status'; -import type { Team } from '../types'; -import { shortRepo } from '../format'; -import { useScope, type Scope } from '../prefs'; -import { usePopover } from './usePopover'; - -/** - * The one filter control: a popover with team presets, people, and repos. - * A full selection commits as empty ("everything") so new repos and new - * teammates never get silently excluded by a stale saved list. - */ -export function ScopeControl({ - pulls, - teams, - hiddenRepos, -}: { - pulls: DerivedPull[]; - teams: Team[]; - /** hide-by-default repos: shown here too, marked, so you can scope one in */ - hiddenRepos?: ReadonlySet; -}) { - const [scope, setScope] = useScope(); - const { - open, - setOpen, - rootRef: ref, - panelRef, - triggerRef, - } = usePopover(); - - const repoCounts = new Map(); - const authorCounts = new Map(); - for (const p of pulls) { - repoCounts.set(p.data.repo, (repoCounts.get(p.data.repo) ?? 0) + 1); - authorCounts.set(p.data.user.login, (authorCounts.get(p.data.user.login) ?? 0) + 1); - } - // A saved scope can name people or repos with no open PRs today. They - // still filter, so they must stay visible (and uncheckable) or the scope - // becomes impossible to undo except by clearing everything. - for (const name of scope.authors) { - if (!authorCounts.has(name)) authorCounts.set(name, 0); - } - for (const name of scope.repos) { - if (!repoCounts.has(name)) repoCounts.set(name, 0); - } - // hide-by-default repos belong here too, so a hidden repo can be scoped in - // even when it has no open PRs today - if (hiddenRepos) - for (const name of hiddenRepos) if (!repoCounts.has(name)) repoCounts.set(name, 0); - const repos = [...repoCounts.entries()].sort((a, b) => b[1] - a[1]); - const authors = [...authorCounts.entries()].sort((a, b) => b[1] - a[1]); - - const scopedCount = scope.repos.length + scope.authors.length; - const label = scopedCount - ? `Scope: ${[ - scope.repos.length && - `${scope.repos.length} ${scope.repos.length === 1 ? 'repo' : 'repos'}`, - scope.authors.length && - `${scope.authors.length} ${scope.authors.length === 1 ? 'person' : 'people'}`, - ] - .filter(Boolean) - .join(' · ')}` - : 'Scope: everything'; - - const commit = (key: keyof Scope, next: string[], all: string[]) => - setScope({ ...scope, [key]: all.length && next.length === all.length ? [] : next }); - - const toggle = (key: keyof Scope, name: string, all: string[]) => { - const cur = scope[key].length ? [...scope[key]] : [...all]; - const i = cur.indexOf(name); - if (i >= 0) cur.splice(i, 1); - else cur.push(name); - commit(key, cur, all); - }; - - const section = (key: keyof Scope, list: [string, number][], strip: boolean) => - list.map(([name, count]) => ( - - )); - - return ( - - - {scopedCount > 0 && ( - - )} - {open && ( - - - - - {teams.length > 0 && ( - <> - - Teams - - - {teams.map(t => ( - - ))} - - - )} - - People - - {section('authors', authors, false)} - - Repos - - {section('repos', repos, true)} - - )} - - ); -} diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 9edd7b6f..f3bbf07c 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { markAllSeen } from '../store'; -import { type Settings as SettingsShape, setSettings, useSettings } from '../settings'; +import { type Settings as SettingsShape, setRepoPref, setSettings, useSettings } from '../settings'; +import { RepoManagerGroup } from './RepoManager'; const LENS_OPTIONS: [string, string][] = [ ['review', 'Review'], @@ -104,7 +105,14 @@ function NumberField({ ); } -export function Settings() { +export function Settings({ + repos, + orgHidden, +}: { + /** every known repo with its open-PR count, for the repo manager */ + repos: { name: string; count: number }[]; + orgHidden: ReadonlySet; +}) { const [open, setOpen] = useState(false); const s = useSettings(); const panelRef = useRef(null); @@ -232,8 +240,40 @@ export function Settings() { onChange={ageRotDays => set({ ageRotDays })} /> + + set({ draftsMode })} + /> + + + set({ showCryo: v === 'show' })} + /> + + + ; + +/** + * The durable state of a repo: your override wins, else the org baseline. + * 'muted' = you hid it; 'org-hidden' = off by default for everyone and you + * haven't unhidden it; 'shown' = on your board. + */ +export function repoState( + repo: string, + orgHidden: ReadonlySet, + prefs: RepoPrefs +): RepoState { + const pref = prefs[repo]; + if (pref === 'mute') return 'muted'; + if (pref === 'show') return 'shown'; + return orgHidden.has(repo) ? 'org-hidden' : 'shown'; +} + +/** Hidden by default (muted or org-hidden) — before any session reveal. */ +export function repoHidden( + repo: string, + orgHidden: ReadonlySet, + prefs: RepoPrefs +): boolean { + return repoState(repo, orgHidden, prefs) !== 'shown'; +} diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index a4d154d1..dd618180 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -21,6 +21,14 @@ export interface Settings { ageRotDays: number; /** seconds of attention before leaving stamps "last seen" (the glance guard) */ seenAfterSecs: number; + /** per-repo override of the org baseline: 'mute' hides a shown repo, + * 'show' reveals an org-hidden one. Absent = follow the org default. */ + repoPrefs: Record; + /** your default for other people's drafts: 'mine' hides them (your own + * always show), 'all' shows everyone's */ + draftsMode: 'mine' | 'all'; + /** your default for Cryogenic-Storage (parked) PRs */ + showCryo: boolean; } export const DEFAULT_SETTINGS: Settings = { @@ -30,6 +38,9 @@ export const DEFAULT_SETTINGS: Settings = { ageWarnDays: STARVE_DAYS, ageRotDays: ROT_DAYS, seenAfterSecs: 45, + repoPrefs: {}, + draftsMode: 'mine', + showCryo: false, }; const KEY = 'pd2.settings'; @@ -59,6 +70,14 @@ export function setSettings(patch: Partial) { for (const fn of listeners) fn(); } +/** Set or clear one repo's visibility override. null follows the org default. */ +export function setRepoPref(repo: string, pref: 'mute' | 'show' | null) { + const next = { ...settings.repoPrefs }; + if (pref == null) delete next[repo]; + else next[repo] = pref; + setSettings({ repoPrefs: next }); +} + export function useSettings(): Settings { return useSyncExternalStore( fn => { From e33a3b313747fca9394c3695f04025232c91c1f1 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 13:55:06 -0700 Subject: [PATCH 046/381] Move "changed" to a Review lane, clear into the Filters popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three toolbar refinements. The "changed since your last look" chip leaves the bar entirely and becomes a lane in Review — "Changed since your last look", newest change first — so the delta collects at the top the way "Yours to do" collects your actions, instead of being a board-narrowing toggle. The whole onlyChanged filter (state, URL param, banner button) is gone with it; the banner slims to just the merged-or-closed-while-away count. The Filters reset stops being a separate ✕ button sitting beside the trigger and moves inside the popover as a "Clear filters" footer, shown only when there's something transient to clear (scope, reveal, drafts override) — never for a durable mute, which is your board, not a filter. And a light styling pass on the toolbar now that it's less crowded: the search box gets a leading magnifier and a touch more width, and the row reads tabs → filters → search without the two removed chips. Co-Authored-By: Claude Fable 5 --- frontend-v2/README.md | 7 +- frontend-v2/src/app.tsx | 111 +++++++------------------ frontend-v2/src/components/Filters.tsx | 37 +++++---- frontend-v2/src/views/Review.tsx | 16 ++++ 4 files changed, 70 insertions(+), 101 deletions(-) diff --git a/frontend-v2/README.md b/frontend-v2/README.md index 158dfe17..f2450fe1 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -87,9 +87,10 @@ serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol - **Changed since your last look.** A last-seen marker in localStorage, stamped when you leave (pagehide) and only after ~45 visible seconds, so a glance at another tab can't erase the weekend's delta. Solid dot = new PR, - ring = updated; opening a PR clears its dot. A persistent `● changed N` - toggle sits in the filter row (not just the arrival banner) to narrow the - board to those PRs, and the banner also counts merges while you were away. + ring = updated; opening a PR clears its dot. What changed is a **lane** in + Review (newest first), not a bar toggle — it collects the delta at the top + the way "Yours to do" collects your actions, rather than filtering the board. + A slim banner still counts the PRs merged or closed while you were away. - **Keyboard:** `/` filter, `j`/`k` walk rows, `Enter` opens, `c` copies the focused row's branch. diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 72f5cdfb..aa7011c0 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { ago, n, shortRepo } from './format'; import type { DerivedPull } from './model/status'; import type { Team } from './types'; -import { isFresh, usePulldasher } from './store'; +import { usePulldasher } from './store'; import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy'; import { loadSiteConfig, primeScope, useScope } from './prefs'; import { getSettings, useSettings } from './settings'; @@ -41,7 +41,6 @@ interface HashState { reveal: string[]; /** session override of the drafts default (null = use the durable default) */ drafts: 'mine' | 'all' | null; - changed: boolean; } function readHash(): HashState { @@ -63,7 +62,6 @@ function readHash(): HashState { hidden: p.get('hidden') === '1', reveal: p.get('show')?.split(',').filter(Boolean) ?? [], drafts: p.get('drafts') === 'all' ? 'all' : p.get('drafts') === 'mine' ? 'mine' : null, - changed: p.get('changed') === '1', }; } @@ -78,7 +76,6 @@ function buildHash(s: HashState): string { if (s.hidden) p.set('hidden', '1'); if (s.reveal.length) p.set('show', s.reveal.join(',')); if (s.drafts) p.set('drafts', s.drafts); - if (s.changed) p.set('changed', '1'); return p.toString(); } @@ -168,7 +165,6 @@ export function App() { const [person, setPerson] = useState(() => urlState.person); const [team, setTeam] = useState(() => urlState.team); const [query, setQuery] = useState(() => urlState.q); - const [onlyChanged, setOnlyChanged] = useState(() => urlState.changed); // "hidden" is two off-by-default groups (Cryogenic-Storage PRs, quiet // repos). showAll reveals both; reveal names individual groups to show. const [showAll, setShowAll] = useState( @@ -220,7 +216,6 @@ export function App() { hidden: showAll, reveal, drafts: draftsMode !== settings.draftsMode ? draftsMode : null, - changed: onlyChanged, }); if (next === location.hash.slice(1)) return; const prev = prevView.current; @@ -231,18 +226,7 @@ export function App() { } else { history.replaceState(null, '', next ? `#${next}` : location.pathname + location.search); } - }, [ - lens, - person, - team, - query, - scope, - showAll, - reveal, - draftsMode, - settings.draftsMode, - onlyChanged, - ]); + }, [lens, person, team, query, scope, showAll, reveal, draftsMode, settings.draftsMode]); useEffect(() => { const onHash = () => { const h = readHash(); @@ -253,7 +237,6 @@ export function App() { setShowAll(h.hidden); setReveal(h.reveal); setDraftsMode(h.drafts ?? getSettings().draftsMode); - setOnlyChanged(h.changed); }; window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); @@ -384,20 +367,13 @@ export function App() { draftsMode, ]); - // changed-only and the banner count share one predicate (bots excluded, - // acked rows drop out) so the toggle always shows exactly what the banner - // promised - const scoped = useMemo( - () => - onlyChanged ? inScope.filter(p => !isBot(p) && isFresh(p.data, lastSeen, acked)) : inScope, - [inScope, onlyChanged, lastSeen, acked, isBot] - ); - + // "what changed since your last look" is now a lane in Review, not a bar + // toggle, so the pool is just the scoped board + const scoped = inScope; const humans = scoped.filter(p => !isBot(p)); const bots = scoped.filter(isBot); - const changedCount = inScope.filter(p => !isBot(p) && isFresh(p.data, lastSeen, acked)).length; // "did my PR merge over the weekend" is the cheapest answer the board can - // give — it belongs in the banner, not buried in a fold + // give — a slim banner, since the open-PR changes live in the lane const mergedCount = closed.filter( p => (Date.parse(p.closed_at ?? '') / 1000 || 0) > lastSeen ).length; @@ -414,18 +390,8 @@ export function App() { }, [pulls, hiddenRepos, settings.repoPrefs]); const cryoCount = pulls.filter(p => p.cryo).length; - // how many PRs are hidden *right now*, given mutes/org-hides and reveals - const hiddenCount = showAll - ? 0 - : pulls.filter(p => { - const hiddenRepo = - repoHidden(p.data.repo, hiddenRepos, settings.repoPrefs) && - !revealedRepo(p.data.repo); - const cryoHidden = p.cryo && !settings.showCryo && !reveal.includes(CRYO_KEY); - return hiddenRepo || cryoHidden; - }).length; - const isScoped = scope.repos.length || scope.authors.length || query || onlyChanged; + const isScoped = scope.repos.length || scope.authors.length || query; const onPerson = useCallback((login: string) => { setPerson(login); @@ -524,28 +490,26 @@ export function App() { cryoCount={cryoCount} draftsMode={draftsMode} setDraftsMode={setDraftsMode} - hiddenCount={hiddenCount} - /> - setQuery(e.target.value)} - className="h-8 w-[170px] max-w-full grow rounded-lg border border-line bg-surface px-2.5 text-[13px] sm:grow-0" /> - {(changedCount > 0 || onlyChanged) && ( - setOnlyChanged(v => !v)} - title="PRs updated since your last visit. The marker only advances after you've had the board open a while, so a quick glance won't clear the weekend's changes. Click to show just these." + + - - changed{changedCount > 0 ? ` ${changedCount}` : ''} - - )} + + + setQuery(e.target.value)} + className="h-8 w-[190px] max-w-full grow rounded-lg border border-line bg-surface pr-2.5 pl-8 text-[13px] sm:grow-0" + /> +
{legacy && ( )} - {(changedCount > 0 || mergedCount > 0) && !query && ( + {mergedCount > 0 && !query && ( - {changedCount > 0 && ( - <> - {n(changedCount, 'PR')} changed - - )} - {changedCount > 0 && mergedCount > 0 && ' · '} - {mergedCount > 0 && ( - <> - {mergedCount} merged or closed - - )}{' '} - since your last look + {mergedCount} merged or closed since your last + look - {changedCount > 0 && ( - - )} )} diff --git a/frontend-v2/src/components/Filters.tsx b/frontend-v2/src/components/Filters.tsx index d014b77d..be5a3adb 100644 --- a/frontend-v2/src/components/Filters.tsx +++ b/frontend-v2/src/components/Filters.tsx @@ -29,7 +29,6 @@ export function Filters({ cryoCount, draftsMode, setDraftsMode, - hiddenCount, }: { pulls: DerivedPull[]; /** all repos with open-PR counts (org-hidden and muted included) */ @@ -43,7 +42,6 @@ export function Filters({ cryoCount: number; draftsMode: 'mine' | 'all'; setDraftsMode: (m: 'mine' | 'all') => void; - hiddenCount: number; }) { const pop = usePopover(); const [scope, setScope] = useScope(); @@ -87,8 +85,16 @@ export function Filters({ if (draftsMode === 'all') bits.push('all drafts'); if (showAll) bits.push('all hidden shown'); else if (reveal.length) bits.push(`${reveal.length} revealed`); - const active = bits.length > 0 || scope.repos.length > 0 || scope.authors.length > 0; + const active = bits.length > 0; const summary = bits.length ? bits.join(' · ') : 'All repos'; + // what "Clear" would actually change — the transient filters, not your + // durable mutes (those are your board; unmute them in the list or Settings) + const clearable = + scope.repos.length > 0 || + scope.authors.length > 0 || + reveal.length > 0 || + showAll || + draftsMode !== settings.draftsMode; const reset = () => { setScope({ repos: [], authors: [] }); @@ -209,21 +215,10 @@ export function Filters({ {summary} - + - {(active || hiddenCount > 0) && ( - - )} {pop.open && (
)} + + {clearable && ( +
+ +
+ )} )} diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index e1319fdc..d20c6670 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -2,6 +2,7 @@ import type { DerivedPull, Status } from '../model/status'; import { pullKey } from '../format'; import { crSort } from '../model/sort'; import { authorMove, reviewerMove } from '../model/actions'; +import { isFresh } from '../store'; import type { PullData } from '../types'; import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; import { AnnotatedRow, Fold, FoldRows, Lane, RestGroup, Truncated } from '../components/Lane'; @@ -99,6 +100,14 @@ export function Review({ const ciRed = byStatus('ci_red'); const drafts = byStatus('draft'); + // Changed since your last look: everything that moved (new or updated), + // collected at the top instead of a bar toggle — newest change first. A + // pull can also live in a lane below; this is the "what happened while I + // was away" glance, not an exclusive bucket. + const changed = pulls + .filter(p => isFresh(p.data, opts.lastSeen, opts.acked)) + .sort((a, b) => Date.parse(b.data.updated_at) - Date.parse(a.data.updated_at)); + // bots/shipped stay reachable even when no human PRs need review const empty = !pulls.length && !bots.length && !closed.length; if (empty) { @@ -123,6 +132,13 @@ export function Review({ )} + Date: Fri, 17 Jul 2026 14:01:00 -0700 Subject: [PATCH 047/381] =?UTF-8?q?Right-align=20search,=20move=20legend?= =?UTF-8?q?=20by=20the=20cog,=20restore=20+/=E2=88=92=20on=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toolbar ordering: the search box moves to the right end of the filter row, and the "?" legend lifts up next to the settings cog in the top row, so the two help/config affordances sit together and the filter row reads tabs → filters on the left, search on the right. And the concrete diff size is back: +added −deleted (GitHub's green/red) sits beside the repo#number on every card. It was cut when the weight meter landed, but the abstract five-segment gauge loses the exact number a reviewer often wants; now both are there — the meter for a glance, the count for precision. Co-Authored-By: Claude Fable 5 --- frontend-v2/README.md | 3 +- frontend-v2/src/app.tsx | 40 +++++++++++++-------------- frontend-v2/src/components/Legend.tsx | 7 +++++ frontend-v2/src/components/Row.tsx | 5 +++- frontend-v2/src/components/bits.tsx | 14 ++++++++++ 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/frontend-v2/README.md b/frontend-v2/README.md index f2450fe1..321eaa8b 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -38,7 +38,8 @@ serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol - **Review effort, always shown.** A five-segment meter (light to heavy, color-ramped) rides the rightmost rail of every row, so "can I fit this in the time I have" reads without opening the diff. A cheap prior from diff - size; humans override by reading. + size; humans override by reading. The concrete `+added −deleted` sits beside + the repo for when the gauge isn't precise enough. - **A queue that ranks the right thing.** `src/model/sort.ts` scores by weight, then boosts pulls one stamp from done and credits age, so an old M outranks a fresh S before the starvation cliff. Thresholds are tuned to diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index aa7011c0..2fb92037 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -468,6 +468,7 @@ export function App() { > v1 board +
@@ -491,25 +492,6 @@ export function App() { draftsMode={draftsMode} setDraftsMode={setDraftsMode} /> - - - - - setQuery(e.target.value)} - className="h-8 w-[190px] max-w-full grow rounded-lg border border-line bg-surface pr-2.5 pl-8 text-[13px] sm:grow-0" - /> - {legacy && ( )} - + + + + + setQuery(e.target.value)} + className="h-8 w-[210px] max-w-full grow rounded-lg border border-line bg-surface pr-2.5 pl-8 text-[13px] sm:grow-0" + /> +
diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index da7498af..fe3dd178 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -74,6 +74,13 @@ export function Legend() { `age: hours under a day, amber past ${s.ageWarnDays} days, red past ${s.ageRotDays}. Hover for both clocks` )} {item(, 'review effort, light to heavy, from diff size')} + {item( + + +120{' '} + −30 + , + 'lines added and removed' + )} {item( QAing, 'someone claimed QA by adding the QAing label on GitHub (◉ in columns)' diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 0bdde9ef..0a95fd43 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -3,7 +3,7 @@ import type { DerivedPull } from '../model/status'; import { isIterating } from '../model/status'; import { ago, pullKey } from '../format'; import { ackPull, isFresh, refreshPull } from '../store'; -import { AgeStamp, RepoRef, SigPips, StatusBadge, WeightMeter } from './bits'; +import { AgeStamp, DiffSize, RepoRef, SigPips, StatusBadge, WeightMeter } from './bits'; import { CardShell } from './Card'; export interface RowOptions { @@ -254,6 +254,9 @@ function RowImpl({ <> {opts.badge === false ? null : } + {pull.sizeKnown && ( + + )} {context != null && ( + ); +} + /** * Sign-off state as a pip meter: one square per required stamp, in a * fixed-width slot so CR, QA, and age land at the same x down a board. Filled From a54344fd73883e16dbae6820dc6ef32d0c8c7f3a Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 14:10:41 -0700 Subject: [PATCH 048/381] Open the sign-off popover on hover, not just click The stamp ledger only revealed who signed on click, so scanning a board for review history meant a click per row. Make it a hover preview: brush the pips and the who/when panel appears, move away and it closes. A click still pins it open and moves focus in for keyboard users, so the drill-down stays reachable without a mouse. Hover-open deliberately never steals focus (only click/keyboard does), so sweeping the cursor across a lane can't yank the page around. Also anchor the pop animation to the panel's own top-right corner so it grows from where it's pinned instead of scaling across the row. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/bits.tsx | 10 ++--- frontend-v2/src/components/usePopover.ts | 56 ++++++++++++++++++++++-- frontend-v2/src/styles.css | 4 ++ 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 2a1b7d07..6a0d465f 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -248,7 +248,7 @@ export function SigPips({ me?: string; sigs: Signature[]; }) { - const pop = usePopover(); + const pop = usePopover({ hover: true }); const pips = ; if (!sigs.length) return pips; @@ -265,14 +265,14 @@ export function SigPips({ ); return ( - + - {pop.open && ( - ( + - - {filteredHidden.map(r => - repoRow(r.name, r.count, repoState(r.name, orgHidden, prefs)) - )} - - )} - {cryoCount > 0 && ( - - )} - - )} + + + + {summary} + + ▾ + + + )} + > +
+ {tabBtn('repos', 'Repos')} + {tabBtn('people', 'People')} + {tabBtn('drafts', 'Drafts')} +
- {tab === 'people' && ( - <> - {teams.length > 0 && ( -
- {teams.map(t => ( - - ))} -
- )} - {search(peopleQuery, setPeopleQuery, 'Filter people')} - {authors - .filter(([login]) => - login.toLowerCase().includes(peopleQuery.toLowerCase()) - ) - .map(([login, count]) => ( - - ))} - + {tab === 'repos' && ( + <> + {search(repoQuery, setRepoQuery, 'Filter repos')} + {filteredShown.map(r => repoRow(r.name, r.count, 'shown'))} + {filteredShown.length === 0 && ( +
No repos match.
)} - - {tab === 'drafts' && ( -
-
+ {filteredHidden.length > 0 && ( +
+ + Muted & org-hidden ({filteredHidden.length}) + + {filteredHidden.map(r => + repoRow(r.name, r.count, repoState(r.name, orgHidden, prefs)) + )} +
+ )} + {cryoCount > 0 && ( + + )} + + )} + + {tab === 'people' && ( + <> + {teams.length > 0 && ( +
+ {teams.map(t => ( -
-

- {draftsMode === 'mine' - ? 'Other people’s drafts are hidden. Your own drafts always show.' - : 'Everyone’s drafts show.'} -

+ ))}
)} - - {clearable && ( -
- -
- )} - + + toggle( + 'authors', + login, + authors.map(([l]) => l) + ) + } + /> + + {login} + {count || ''} + + ))} + + )} + + {tab === 'drafts' && ( +
+
+ + +
+

+ {draftsMode === 'mine' + ? 'Other people’s drafts are hidden. Your own drafts always show.' + : 'Everyone’s drafts show.'} +

+
+ )} + + {clearable && ( +
+ +
)} - + ); } diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index fe3dd178..49d7451c 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -1,6 +1,6 @@ import { useSettings } from '../settings'; import { WeightMeter } from './bits'; -import { usePopover } from './usePopover'; +import { Popover } from './Popover'; /** * The one-stop decoder for the board's invented vocabulary. New hires can't @@ -8,7 +8,6 @@ import { usePopover } from './usePopover'; * visible answer the review pass found missing. */ export function Legend() { - const pop = usePopover(); const s = useSettings(); const item = (term: React.ReactNode, def: string) => ( @@ -19,114 +18,108 @@ export function Legend() { ); return ( - - - {pop.open && ( - ( + )} - + > + Reading the board + {item( + stamp, + 'a CR or QA sign-off, left as a comment on the PR' + )} + {item( + + CR + + + , + 'CR / QA sign-offs: one pip per required stamp, filled = done, hollow = still needed. Click a row’s pips for who signed' + )} + {item( + + + + , + 'an amber pip means a push invalidated a stamp: a re-stamp is owed' + )} + {item( + + + , + 'the dotted underline marks a slot you stamped' + )} + {item( + + {s.ageWarnDays}d/ + {s.ageRotDays}d + , + `age: hours under a day, amber past ${s.ageWarnDays} days, red past ${s.ageRotDays}. Hover for both clocks` + )} + {item(, 'review effort, light to heavy, from diff size')} + {item( + + +120{' '} + −30 + , + 'lines added and removed' + )} + {item( + QAing, + 'someone claimed QA by adding the QAing label on GitHub (◉ in columns)' + )} + {item( + iterating, + 'changed in the last 30 minutes, may still be moving' + )} + {item( + Dev blocked, + 'a reviewer requested changes: the author’s move' + )} + {item( + Deploy hold, + 'done, deliberately held from shipping: ask the holder' + )} + {item( + Can't merge, + 'signed off but conflicted or on an unmerged parent: author rebases' + )} + {item( + + + + , + 'changed since your last visit: solid = new PR, ring = updated (tracked while you’re away). Opening one clears it' + )} + {item( + + + + , + 'the eye selector reveals off-by-default PRs: Cryogenic Storage and quiet repos' + )} + {item( + / j k c, + 'keys: / filter · j/k walk rows · Enter opens · c copies the branch' + )} + ); } diff --git a/frontend-v2/src/components/Popover.tsx b/frontend-v2/src/components/Popover.tsx new file mode 100644 index 00000000..f364bcb0 --- /dev/null +++ b/frontend-v2/src/components/Popover.tsx @@ -0,0 +1,68 @@ +import type { ReactNode, Ref } from 'react'; +import { usePopover } from './usePopover'; + +export interface PopoverTriggerProps { + ref: Ref; + 'aria-haspopup': 'dialog'; + 'aria-expanded': boolean; + onClick: () => void; +} + +/** + * The house popover: a trigger button plus an absolutely-positioned + * role=dialog panel, sharing the click-away / Escape / focus discipline in + * usePopover. Legend, the sign-off ledger, and Filters all render through this, + * so the panel chrome and ARIA plumbing live in one place instead of being + * hand-rolled three times. (Settings is deliberately NOT one of these — it's a + * portal drawer with a scrim, a different dismissal model.) + * + * The trigger is a render prop: spread the given props onto your own - {pop.open && ( + ( + + )} + > + {label} stamps + {rows.map(s => ( - {label} stamps - {rows.map(s => ( + + {s.data.user.login} + {s.data.user.login === me && (you)} + + {ago(Date.parse(s.data.created_at) / 1000)} ago + + {s.data.active ? ( + + ) : ( - - {s.data.user.login} - {s.data.user.login === me && (you)} - - {ago(Date.parse(s.data.created_at) / 1000)} ago - - {s.data.active ? ( - - ) : ( - - )} - - ))} + className="pip pip-stale" + title="invalidated by a later push — a re-stamp is owed" + /> + )} - )} - + ))} + ); } From 95e780b4e32ec7d6e8034735c492ca13f1dc46cb Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 14:25:11 -0700 Subject: [PATCH 052/381] Share the Segmented control between Settings and Filters Settings owned a Segmented pill control (a radiogroup for a small closed choice), while Filters' Mine/All drafts toggle hand-rolled the same visual as two aria-pressed buttons, duplicating the class strings a third time. Move Segmented into bits.tsx and render the drafts toggle through it. Besides deleting the duplication, this upgrades the drafts toggle to one radiogroup, which reads as 'pick exactly one' instead of two independent toggles. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Filters.tsx | 37 +++++++---------------- frontend-v2/src/components/Settings.tsx | 37 +---------------------- frontend-v2/src/components/bits.tsx | 40 +++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 62 deletions(-) diff --git a/frontend-v2/src/components/Filters.tsx b/frontend-v2/src/components/Filters.tsx index 2a7678c3..d3e8b75a 100644 --- a/frontend-v2/src/components/Filters.tsx +++ b/frontend-v2/src/components/Filters.tsx @@ -5,7 +5,7 @@ import { shortRepo } from '../format'; import { useScope, type Scope } from '../prefs'; import { setRepoPref, useSettings } from '../settings'; import type { Team } from '../types'; -import { Avatar } from './bits'; +import { Avatar, Segmented } from './bits'; import { Popover } from './Popover'; type Tab = 'repos' | 'people' | 'drafts'; @@ -317,31 +317,16 @@ export function Filters({ {tab === 'drafts' && (
-
- - +
+

{draftsMode === 'mine' diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index f3bbf07c..2c9294bd 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { markAllSeen } from '../store'; import { type Settings as SettingsShape, setRepoPref, setSettings, useSettings } from '../settings'; +import { Segmented } from './bits'; import { RepoManagerGroup } from './RepoManager'; const LENS_OPTIONS: [string, string][] = [ @@ -37,42 +38,6 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr ); } -/** A pill segmented control — the house pattern for a small closed choice. */ -function Segmented({ - value, - options, - onChange, - ariaLabel, -}: { - value: T; - options: [T, string][]; - onChange: (next: T) => void; - ariaLabel: string; -}) { - return ( -

- {options.map(([val, label]) => ( - - ))} -
- ); -} - /** A small bounded number stepper (days, seconds). */ function NumberField({ value, diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index ae322ef8..cfecb286 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -419,6 +419,46 @@ export function RepoRef({ repo, number }: { repo: string; number: number }) { ); } +/** + * A pill segmented control — the house pattern for a small closed choice + * (default view, density, Mine/All drafts). One radiogroup, so it reads as + * "pick exactly one" to a screen reader instead of a row of toggle buttons. + */ +export function Segmented({ + value, + options, + onChange, + ariaLabel, +}: { + value: T; + options: [T, string][]; + onChange: (next: T) => void; + ariaLabel: string; +}) { + return ( +
+ {options.map(([val, label]) => ( + + ))} +
+ ); +} + export function EmptyState({ title, sub }: { title: string; sub: string }) { return (
From 1f821367c44b4ecf43733fdc3eeefbd2bb50a3fa Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 14:26:59 -0700 Subject: [PATCH 053/381] Back settings and scope with one persistent-store factory settings.ts and prefs.ts each hand-rolled the same per-browser store: load a JSON blob merged over defaults, keep a listener Set, and expose a useSyncExternalStore hook plus a persisting setter. Extract createPersistentStore(key, defaults) into storage.ts and back both on it. The factory keeps the one real divergence as a first-class method: prime() updates the live value and notifies without persisting, which is how primeScope lets a shared URL seed the board without overwriting the saved one. store.ts's lastSeen stays as-is on purpose: its write is gated on attended seconds and it's woven into the larger snapshot machinery, so a generic factory would either lose the gating or bloat to fit one caller. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/prefs.ts | 39 ++++++--------------------------- frontend-v2/src/settings.ts | 35 ++++++------------------------ frontend-v2/src/storage.ts | 43 +++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 62 deletions(-) diff --git a/frontend-v2/src/prefs.ts b/frontend-v2/src/prefs.ts index 3a4fd7fd..fc4934d8 100644 --- a/frontend-v2/src/prefs.ts +++ b/frontend-v2/src/prefs.ts @@ -1,5 +1,4 @@ -import { useCallback, useSyncExternalStore } from 'react'; -import { readStorage, writeStorage } from './storage'; +import { createPersistentStore } from './storage'; import type { Team } from './types'; /** @@ -13,22 +12,7 @@ export interface Scope { authors: string[]; } -const SCOPE_KEY = 'pd2.scope'; - -function loadScope(): Scope { - try { - return { - repos: [], - authors: [], - ...(JSON.parse(readStorage(SCOPE_KEY) ?? '{}') as object), - }; - } catch { - return { repos: [], authors: [] }; - } -} - -let scope = loadScope(); -const listeners = new Set<() => void>(); +const store = createPersistentStore('pd2.scope', { repos: [], authors: [] }); /** * Apply a scope from a shared URL for this session WITHOUT persisting it — @@ -37,24 +21,13 @@ const listeners = new Set<() => void>(); * as usual. */ export function primeScope(next: Scope) { - scope = next; - for (const fn of listeners) fn(); + store.prime(next); } export function useScope(): [Scope, (next: Scope) => void] { - const value = useSyncExternalStore( - fn => { - listeners.add(fn); - return () => listeners.delete(fn); - }, - () => scope - ); - const setScope = useCallback((next: Scope) => { - scope = next; - writeStorage(SCOPE_KEY, JSON.stringify(next)); - for (const fn of listeners) fn(); - }, []); - return [value, setScope]; + // store.set is a stable module-level reference, so it's safe to hand out + // directly as the setter without wrapping it in useCallback. + return [store.useValue(), store.set]; } export interface SiteConfig { diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index dd618180..91e80009 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -1,6 +1,5 @@ -import { useSyncExternalStore } from 'react'; import { ROT_DAYS, STARVE_DAYS } from './model/status'; -import { readStorage, writeStorage } from './storage'; +import { createPersistentStore } from './storage'; /** * User settings: the knobs that are a matter of personal taste, not team @@ -43,47 +42,25 @@ export const DEFAULT_SETTINGS: Settings = { showCryo: false, }; -const KEY = 'pd2.settings'; - -function load(): Settings { - try { - return { - ...DEFAULT_SETTINGS, - ...(JSON.parse(readStorage(KEY) ?? '{}') as Partial), - }; - } catch { - return { ...DEFAULT_SETTINGS }; - } -} - -let settings = load(); -const listeners = new Set<() => void>(); +const store = createPersistentStore('pd2.settings', DEFAULT_SETTINGS); /** Plain getter for non-React readers (the store's glance guard). */ export function getSettings(): Settings { - return settings; + return store.get(); } export function setSettings(patch: Partial) { - settings = { ...settings, ...patch }; - writeStorage(KEY, JSON.stringify(settings)); - for (const fn of listeners) fn(); + store.set({ ...store.get(), ...patch }); } /** Set or clear one repo's visibility override. null follows the org default. */ export function setRepoPref(repo: string, pref: 'mute' | 'show' | null) { - const next = { ...settings.repoPrefs }; + const next = { ...store.get().repoPrefs }; if (pref == null) delete next[repo]; else next[repo] = pref; setSettings({ repoPrefs: next }); } export function useSettings(): Settings { - return useSyncExternalStore( - fn => { - listeners.add(fn); - return () => listeners.delete(fn); - }, - () => settings - ); + return store.useValue(); } diff --git a/frontend-v2/src/storage.ts b/frontend-v2/src/storage.ts index bd330df1..e8d57d26 100644 --- a/frontend-v2/src/storage.ts +++ b/frontend-v2/src/storage.ts @@ -1,3 +1,5 @@ +import { useSyncExternalStore } from 'react'; + /** * localStorage that never throws: privacy modes and blocked-storage settings * make every localStorage touch a potential SecurityError, and a preference @@ -18,3 +20,44 @@ export function writeStorage(key: string, value: string): void { // storage blocked: the preference just doesn't persist } } + +/** + * A per-browser store for one JSON blob (settings, scope): load-with-defaults, + * a listener set, and a useSyncExternalStore hook — the boilerplate settings + * and scope each hand-rolled. `set` persists; `prime` updates the live value + * and notifies WITHOUT persisting (a shared URL must not overwrite your saved + * board). Unknown fields in a stale saved blob fall back to their default, so + * a new field never breaks an old blob. + */ +export function createPersistentStore(key: string, defaults: T) { + const load = (): T => { + try { + return { ...defaults, ...(JSON.parse(readStorage(key) ?? '{}') as Partial) }; + } catch { + return { ...defaults }; + } + }; + let value = load(); + const listeners = new Set<() => void>(); + const emit = () => { + for (const fn of listeners) fn(); + }; + const subscribe = (fn: () => void) => { + listeners.add(fn); + return () => listeners.delete(fn); + }; + return { + get: () => value, + set(next: T) { + value = next; + writeStorage(key, JSON.stringify(value)); + emit(); + }, + prime(next: T) { + value = next; + emit(); + }, + subscribe, + useValue: () => useSyncExternalStore(subscribe, () => value), + }; +} From af2fa6c19377bddd91bf1e6499b97889dc6506b5 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 14:28:05 -0700 Subject: [PATCH 054/381] Share one header and the row container across Lane and RestGroup Lane and RestGroup each re-typed the same title/subtitle header row, and RestGroup re-typed the exact rounded-surface container the Rows component already exports. Factor a GroupHeader (title, optional sub, optional right-aligned count) that both use, and have RestGroup render through Rows. The collapsible BoardColumn header stays separate: its title lives inside a collapse button, which is a different affordance, not the same header. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Lane.tsx | 32 +++++++++++++++++------------ 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index b8e6c4c2..65bac97b 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -9,6 +9,22 @@ export function Rows({ children }: { children: ReactNode }) { ); } +/** The shared lane/group header: title, optional subtitle, optional right-aligned count. */ +function GroupHeader({ title, sub, count }: { title: string; sub?: string; count?: number }) { + return ( +
+

{title}

+ {sub && {sub}} + {count != null && ( + <> + + {count} + + )} +
+ ); +} + export function Lane({ title, sub, @@ -31,12 +47,7 @@ export function Lane({ if (!pulls.length && !children) return null; return (
-
-

{title}

- {sub && {sub}} - - {count ?? pulls.length} -
+ {children} @@ -176,13 +187,8 @@ export function RestGroup({ }) { return (
- {title && ( -
-

{title}

- {sub} -
- )} -
{children}
+ {title && } + {children}
); } From be181ddbdd22d5e8e4850e8fd38496e7111b6c15 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 14:38:07 -0700 Subject: [PATCH 055/381] Drop the redundant stats shortcut from the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open-count next to the logo doubled as a link into the Stats lens ("N open · stats ▸"). Stats is already one of the lens tabs a row below, so the shortcut was a second door to the same place. Keep the open count as plain ambient text beside the live-connection dot and remove the link. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 2fb92037..a758de7e 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -448,17 +448,12 @@ export function App() { > live updates {connection} - + open + {me ? `signed in as ${me}` : '…'} Date: Fri, 17 Jul 2026 14:38:17 -0700 Subject: [PATCH 056/381] Align sign-off pips to the start of their slot, not the end The CR/QA pips sat justify-end in a fixed-width slot, so the empty space landed between the label and its pips and shrank as the required-stamp count grew: a 1-stamp card and a 2-stamp card showed a different CR-to-pip gap, so the spacing looked inconsistent card to card. Switch the slot (and the not-required dash) to justify-start: pips now hug the label with a constant gap, and the leftover space falls after them where it reads as ordinary column padding. The slot keeps its fixed width, so the QA and age columns still line up down the board. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/bits.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index cfecb286..f2d98d31 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -217,13 +217,13 @@ export function Pips({ {label} {none ? ( - + ) : ( From 6eb89e59d215d1591f158db0111a1dfb1c2f269e Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 14:41:36 -0700 Subject: [PATCH 057/381] Portal the popover panel so it can't be clipped or sink behind rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sign-off ledger opened inside a row, and the row list is a rounded, overflow-hidden surface — so the panel was clipped at the card's bounds and tangled with the row's stacking context, reading as translucent where row content showed through. Render the panel through createPortal to document.body with fixed positioning derived from the trigger's rect, following it on scroll/resize, so it escapes the clip and sits above everything at full opacity. Because a portaled panel is no longer a DOM descendant of the popover root, teach usePopover's click-away to treat the panel ref as inside too; otherwise clicking a control in the (portaled) Filters panel would count as an outside click and close it. Hover handlers ride the panel as well so the mouse can travel from trigger into the panel without it closing. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Popover.tsx | 70 ++++++++++++++++++------ frontend-v2/src/components/usePopover.ts | 5 +- 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/frontend-v2/src/components/Popover.tsx b/frontend-v2/src/components/Popover.tsx index f364bcb0..1a43579d 100644 --- a/frontend-v2/src/components/Popover.tsx +++ b/frontend-v2/src/components/Popover.tsx @@ -1,4 +1,5 @@ -import type { ReactNode, Ref } from 'react'; +import { useLayoutEffect, useState, type ReactNode, type Ref } from 'react'; +import { createPortal } from 'react-dom'; import { usePopover } from './usePopover'; export interface PopoverTriggerProps { @@ -9,12 +10,17 @@ export interface PopoverTriggerProps { } /** - * The house popover: a trigger button plus an absolutely-positioned - * role=dialog panel, sharing the click-away / Escape / focus discipline in - * usePopover. Legend, the sign-off ledger, and Filters all render through this, - * so the panel chrome and ARIA plumbing live in one place instead of being - * hand-rolled three times. (Settings is deliberately NOT one of these — it's a - * portal drawer with a scrim, a different dismissal model.) + * The house popover: a trigger button plus a role=dialog panel, sharing the + * click-away / Escape / focus discipline in usePopover. Legend, the sign-off + * ledger, and Filters all render through this, so the panel chrome and ARIA + * plumbing live in one place instead of being hand-rolled three times. + * (Settings is deliberately NOT one of these — it's a portal drawer with a + * scrim, a different dismissal model.) + * + * The panel is portaled to document.body and positioned `fixed` off the + * trigger's rect, so it can't be clipped by an ancestor's `overflow: hidden` + * (the row list is a rounded, clipped surface) or trapped under a row's + * stacking context. It follows the trigger on scroll/resize. * * The trigger is a render prop: spread the given props onto your own + {refreshNote && ( + {refreshNote} + )} +
+ + Date: Fri, 17 Jul 2026 14:54:59 -0700 Subject: [PATCH 060/381] Drop "signed in as" from the header Who you're signed in as is low-value chrome that ate horizontal space next to the board controls; your own PRs and stamps already read as "you" in context. Remove it. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index a758de7e..84aeaca7 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -455,7 +455,6 @@ export function App() { open - {me ? `signed in as ${me}` : '…'} Date: Fri, 17 Jul 2026 14:55:00 -0700 Subject: [PATCH 061/381] Replace the freshness dot with a labelled new/updated tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "changed since your last look" marker was a 7px dot in the card's left gutter: hard to see, poorly aligned with the avatar, and its meaning (solid = new PR, ring = updated) was a guess unless you opened the Legend. Replace it with an inline tag in the meta line — a solid brand "new" chip for a brand-new PR, a brand-outline "updated" chip for one that merely changed — so the state is legible and self-describing, and it flows with the row instead of floating in the padding. Move the marker into Row's meta and drop CardShell's fresh prop and the absolutely-positioned dot; the row-flash on first appearance is unchanged. Update the Legend to show the two tags. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Card.tsx | 14 -------------- frontend-v2/src/components/Legend.tsx | 10 +++++----- frontend-v2/src/components/Row.tsx | 4 ++-- frontend-v2/src/components/bits.tsx | 20 ++++++++++++++++++++ 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index 284053ed..ac085500 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -20,7 +20,6 @@ export function CardShell({ repo, number, title, - fresh, onOpen, className = '', meta, @@ -31,8 +30,6 @@ export function CardShell({ repo: string; number: number; title: string; - /** 'new' = opened since your last look (solid dot); 'updated' = changed (ring) */ - fresh?: 'new' | 'updated' | null; onOpen?: () => void; className?: string; /** the whole meta line: badge, repo#number, context, flags, metric rail */ @@ -43,17 +40,6 @@ export function CardShell({
- {/* lives in the padding gutter: a changed card must not indent its content */} - {fresh && ( - - )} diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 49d7451c..682674e0 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -1,5 +1,5 @@ import { useSettings } from '../settings'; -import { WeightMeter } from './bits'; +import { FreshTag, WeightMeter } from './bits'; import { Popover } from './Popover'; /** @@ -97,11 +97,11 @@ export function Legend() { 'signed off but conflicted or on an unmerged parent: author rebases' )} {item( - - - + + + , - 'changed since your last visit: solid = new PR, ring = updated (tracked while you’re away). Opening one clears it' + 'changed since your last visit: “new” is a brand-new PR, “updated” an existing one that changed (tracked while you’re away). Opening one clears it' )} {item( diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 0a95fd43..99f0762c 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -3,7 +3,7 @@ import type { DerivedPull } from '../model/status'; import { isIterating } from '../model/status'; import { ago, pullKey } from '../format'; import { ackPull, isFresh, refreshPull } from '../store'; -import { AgeStamp, DiffSize, RepoRef, SigPips, StatusBadge, WeightMeter } from './bits'; +import { AgeStamp, DiffSize, FreshTag, RepoRef, SigPips, StatusBadge, WeightMeter } from './bits'; import { CardShell } from './Card'; export interface RowOptions { @@ -247,11 +247,11 @@ function RowImpl({ repo={d.repo} number={d.number} title={d.title} - fresh={fresh} onOpen={() => ackPull(key)} className={`${flashOnce(key, !!fresh) ? 'row-fresh' : ''} transition-[background-color] duration-150 motion-reduce:transition-none`} meta={ <> + {fresh && } {opts.badge === false ? null : } {pull.sizeKnown && ( diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 7db7f87e..94a51696 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -70,6 +70,26 @@ export function ClosedBadge({ merged, inline }: { merged: boolean; inline?: bool ); } +/** + * The "changed since your last look" marker, spelled out instead of left as a + * gutter dot you had to decode: a solid brand chip for a brand-new PR, a brand + * outline for one that merely changed. Opening the PR clears it for the + * session. + */ +export function FreshTag({ kind }: { kind: 'new' | 'updated' }) { + const isNew = kind === 'new'; + return ( + + {isNew ? 'new' : 'updated'} + + ); +} + export function Avatar({ login, size = 22, From cad11765f3f45455a7b3241f7282397ecc084732 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 15:00:14 -0700 Subject: [PATCH 062/381] Add a Clear-settings button that wipes local prefs There was no way to get back to a clean slate: a bad age threshold, a stuck scope, or a muted repo you forgot about all just persisted. Add a Clear settings button under the Data group that removes every pd2.* localStorage key (settings, scope, last-seen marker) via clearStoredPrefs() and reloads so the in-memory stores re-init from defaults. It's a two-click confirm (the button arms to "Click again to confirm" for a few seconds) rather than a native dialog, since a wipe shouldn't be one stray click but doesn't warrant a modal for browser-local prefs. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Settings.tsx | 28 +++++++++++++++++++++++++ frontend-v2/src/storage.ts | 15 +++++++++++++ 2 files changed, 43 insertions(+) diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 757554b6..0ac03770 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; +import { clearStoredPrefs } from '../storage'; import { markAllSeen, refreshAll } from '../store'; import { type Settings as SettingsShape, setRepoPref, setSettings, useSettings } from '../settings'; import { Segmented } from './bits'; @@ -84,6 +85,7 @@ export function Settings({ const triggerRef = useRef(null); const [seenNote, setSeenNote] = useState(false); const [refreshNote, setRefreshNote] = useState(''); + const [armReset, setArmReset] = useState(false); useEffect(() => { if (!open) return; @@ -265,6 +267,32 @@ export function Settings({ {refreshNote} )}
+ + + Reset every preference on this browser (theme, filters, muted repos, + last-seen marker) back to defaults. This can’t be undone. + +
+ +
diff --git a/frontend-v2/src/storage.ts b/frontend-v2/src/storage.ts index e8d57d26..23bdc5c6 100644 --- a/frontend-v2/src/storage.ts +++ b/frontend-v2/src/storage.ts @@ -21,6 +21,21 @@ export function writeStorage(key: string, value: string): void { } } +/** + * Wipe every persisted preference — all of our `pd2.` localStorage keys + * (settings, scope, last-seen marker). The caller reloads so the in-memory + * stores re-initialize from their defaults. + */ +export function clearStoredPrefs(): void { + try { + for (const key of Object.keys(localStorage)) { + if (key.startsWith('pd2.')) localStorage.removeItem(key); + } + } catch { + // storage blocked: nothing was persisted to clear + } +} + /** * A per-browser store for one JSON blob (settings, scope): load-with-defaults, * a listener set, and a useSyncExternalStore hook — the boilerplate settings From 1782c93e92ee7d9b32f01ec407d2938f4ce1e3fe Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 15:16:08 -0700 Subject: [PATCH 063/381] Add one rowNote(pull, me) as the single next-action/context line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit found five independent answers to "what's next" (authorMove, reviewerMove, ownVerb, waitingOn, and Row's cue) that phrase the same state differently across lenses and can disagree. Introduce rowNote(pull, me) in the model as the single source every lens will render: the imperative verb when it's your move (enriched with the when/which detail a badge can't carry), otherwise a terse who/when/why for whoever's move it is, never restating the badge's noun. Source-agnostic wording throughout — a CR, re-stamp, or dev block reads the same whether it came from a comment tag or a GitHub review — and "you" substitution everywhere (fixing the "you's" grammar bug the old waitingOn had). Not wired up yet; the next commit routes Row and the views through it. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/actions.ts | 55 ++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index 1246fb5e..95c54001 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -1,3 +1,4 @@ +import { ago } from '../format'; import type { DerivedPull } from './status'; /** @@ -27,3 +28,57 @@ export function reviewerMove(p: DerivedPull, me: string): string | null { if (p.status === 'needs_qa' && p.reqaBy.includes(me)) return 'Re-QA'; return null; } + +/** 'do' = your move (brand, bold imperative); 'wait' = context on someone else's move. */ +export interface RowNote { + text: string; + tone: 'do' | 'wait'; +} + +/** + * The one context line every row renders, in every lens: the imperative when + * it's your move (the verb from authorMove/reviewerMove, enriched with the + * when/which detail the badge can't carry), otherwise a terse who/when/why for + * whoever's move it is. It never restates the status badge — "Needs QA" the + * badge already says; this line adds "alice is QAing", not "needs a QA stamp". + * + * Source-agnostic on purpose: a CR, a re-stamp, or a dev block is the same + * whether it came from a `CR`/`dev_block` comment tag or a GitHub review, so + * the wording never says "requested changes" or "approved". + */ +export function rowNote(p: DerivedPull, me: string): RowNote | null { + const d = p.data; + const who = (logins: string[]) => logins.map(l => (l === me ? 'you' : l)).join(', '); + const pushed = p.headPushedAt ? ` · fix pushed ${ago(p.headPushedAt)} ago` : ''; + + const verb = d.user.login === me ? authorMove(p) : reviewerMove(p, me); + if (verb) { + if (verb === 'Re-stamp') return { text: `Re-stamp${pushed}`, tone: 'do' }; + if (verb === 'Fix CI' && p.ciFailing.length) + return { text: `Fix CI: ${p.ciFailing.join(', ')}`, tone: 'do' }; + return { text: verb, tone: 'do' }; + } + + const wait = (text: string): RowNote => ({ text, tone: 'wait' }); + switch (p.status) { + case 'needs_recr': + return p.recrBy.length ? wait(`waiting on ${who(p.recrBy)}’s re-stamp${pushed}`) : null; + case 'needs_qa': + if (p.qaingBy) return wait(`${who([p.qaingBy])} is QAing`); + if (p.reqaBy.length) return wait(`${who(p.reqaBy)}’s QA stamp needs redoing`); + return null; + case 'needs_cr': + if (p.crHave > 0) return wait(`${p.crHave} of ${d.status.cr_req} CRs`); + return p.starved ? wait(`no CR for ${p.ageDays}d`) : null; + case 'ci_red': + return p.ciFailing.length ? wait(`red: ${p.ciFailing.join(', ')}`) : null; + case 'dev_block': + return p.devBlockedBy.length ? wait(`dev-blocked by ${who(p.devBlockedBy)}`) : null; + case 'deploy_block': + return p.deployBlockedBy.length ? wait(`held by ${who(p.deployBlockedBy)}`) : null; + case 'unmergeable': + return wait(p.conflict ? 'signed off, but conflicts' : 'lands with its parent'); + default: + return null; + } +} From 5ee5744727fa350b4733030855c2e7e77627f551 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 15:21:18 -0700 Subject: [PATCH 064/381] Route every lens through rowNote; cut the duplicate row signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One consistent scheme now, in all lenses: the StatusBadge is the canonical "what state" signal (shown on every row, no more per-lane suppression), and a single rowNote line carries the imperative when it's your move or a terse who/when otherwise. This kills the redundancy the audit found: - Row's cue() and My work's waitingOn() are deleted; both were reimplementing the same status→text mapping with divergent wording. Review's "Yours to do" / My work's "Your move"+"Waiting" now render plain and let rowNote supply the text, so the imperative ("Re-stamp", "Merge it") and the wait note come from one place. AnnotatedRow and Row's note/noteTone props go away. - The WarnFlags "QAing: X" chip is gone — it always duplicated the note's "X is QAing"; the note wins. - "iterating" is suppressed when the note already says "fix pushed …", so a just-repushed re-CR doesn't state the same fact twice. - The badge option (badge:false) is removed along with the dead cue toggle, so the badge is consistently present rather than hidden in three Review lanes. Also drops the now-unused flag-qaing style and FoldRows' unused extra prop, and updates the Legend's QAing entry to the plain-text treatment rows now use. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Lane.tsx | 34 +--------- frontend-v2/src/components/Legend.tsx | 4 +- frontend-v2/src/components/Row.tsx | 93 ++++----------------------- frontend-v2/src/styles.css | 10 --- frontend-v2/src/views/MyWork.tsx | 37 ++--------- frontend-v2/src/views/Review.tsx | 16 ++--- 6 files changed, 32 insertions(+), 162 deletions(-) diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index 65bac97b..ab6f53a0 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -60,42 +60,12 @@ export function Lane({ ); } -/** - * A row carrying the lane's explanation in its own context slot: the action - * verb ("Merge it", "Re-stamp") for your move, or the who-to-nudge note for - * work you're waiting on. Folding it into the meta line (instead of a fixed - * side column) is what lets these rows reflow to a phone. - */ -export function AnnotatedRow({ - pull, - opts, - side, - children, -}: { - pull: DerivedPull; - opts: RowOptions; - side: 'left' | 'right'; - children: ReactNode; -}) { - return ( - - ); -} - /** The standard fold body: capped, expandable rows for a list of pulls. */ -export function FoldRows({ - list, - opts, - extra, -}: { - list: DerivedPull[]; - opts: RowOptions; - extra?: Partial; -}) { +export function FoldRows({ list, opts }: { list: DerivedPull[]; opts: RowOptions }) { return ( {list.map(p => ( - + ))} ); diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 682674e0..598decf0 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -77,8 +77,8 @@ export function Legend() { 'lines added and removed' )} {item( - QAing, - 'someone claimed QA by adding the QAing label on GitHub (◉ in columns)' + is QAing, + 'someone claimed QA by adding the QAing label on GitHub' )} {item( iterating, diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 99f0762c..ea9af57e 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -1,18 +1,15 @@ -import { memo, useState, type ReactNode } from 'react'; +import { memo, useState } from 'react'; import type { DerivedPull } from '../model/status'; import { isIterating } from '../model/status'; -import { ago, pullKey } from '../format'; +import { rowNote } from '../model/actions'; +import { pullKey } from '../format'; import { ackPull, isFresh, refreshPull } from '../store'; import { AgeStamp, DiffSize, FreshTag, RepoRef, SigPips, StatusBadge, WeightMeter } from './bits'; import { CardShell } from './Card'; export interface RowOptions { - /** hide the status badge when the lane already says it */ - badge?: boolean; /** show the open-Nd flag on starved pulls */ aging?: boolean; - /** suppress the cue line when the view says the same thing beside the row */ - cue?: boolean; me: string; lastSeen: number; /** pull keys opened this session (their fresh dots are cleared) */ @@ -42,47 +39,6 @@ function flashOnce(key: string, fresh: boolean): boolean { return true; } -function cue(p: DerivedPull, me: string): string | null { - const d = p.data; - if (p.status === 'needs_recr' && p.recrBy.length) { - const who = p.recrBy.includes(me) ? 'you' : p.recrBy.join(', '); - const wait = p.headPushedAt ? ` · fix pushed ${ago(p.headPushedAt)} ago` : ''; - return `CR’d by ${who}${wait}`; - } - if (p.status === 'ready') { - const idle = p.signedOffAt ? `signed off ${ago(p.signedOffAt)} ago, ` : ''; - return `${idle}${d.user.login === me ? 'you' : d.user.login} can merge`; - } - if (p.status === 'needs_qa') { - if (p.qaingBy) return `${p.qaingBy} is QAing`; - if (p.reqaBy.length) { - const who = p.reqaBy.includes(me) ? 'your QA stamp' : `${p.reqaBy.join(', ')}’s QA stamp`; - return `${who} was invalidated by a push`; - } - return 'CR done, needs a QA stamp'; - } - if (p.status === 'ci_pending') return 'signed off, only CI left'; - if (p.status === 'ci_red' && p.ciFailing.length) return `red: ${p.ciFailing.join(', ')}`; - if (p.status === 'dev_block' && p.devBlockedBy.length) - return `changes requested by ${p.devBlockedBy.join(', ')}`; - if (p.status === 'deploy_block' && p.deployBlockedBy.length) - return `held from deploy by ${p.deployBlockedBy.join(', ')}`; - if (p.status === 'unmergeable') - return p.conflict - ? 'signed off, but conflicts — the author rebases' - : `based on ${d.base.ref}, lands with its parent`; - return null; -} - -/** A login that can't blow out the meta line. */ -function Who({ login }: { login: string }) { - return ( - - {login} - - ); -} - /** Small warning flags that ride along regardless of lane. */ function WarnFlags({ pull }: { pull: DerivedPull }) { return ( @@ -128,14 +84,6 @@ function WarnFlags({ pull }: { pull: DerivedPull }) { external )} - {pull.qaingBy && pull.status === 'needs_qa' && ( - - QAing: - - )} ); } @@ -222,23 +170,14 @@ function MetricRail({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { ); } -function RowImpl({ - pull, - opts, - note, - noteTone, -}: { - pull: DerivedPull; - opts: RowOptions; - /** a lane-supplied phrase for the context slot (verb, or who to nudge) */ - note?: ReactNode; - /** 'do' = your action (brand), 'wait' = a nudge note (muted) */ - noteTone?: 'do' | 'wait'; -}) { +function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const d = pull.data; const key = pullKey(d); const fresh = freshKind(pull, opts); - const context = note ?? (opts.cue === false ? null : cue(pull, opts.me)); + // the one action/context line, the same in every lens (model/actions.ts) + const note = rowNote(pull, opts.me); + // "iterating" and a "fix pushed …" note say the same thing — don't say it twice + const showIterating = isIterating(d) && !note?.text.includes('pushed'); return ( {fresh && } - {opts.badge === false ? null : } + {pull.sizeKnown && ( )} - {context != null && ( + {note && ( - {context} + {note.text} )} - {isIterating(d) && ( + {showIterating && ( a.pull === b.pull && - a.note === b.note && - a.noteTone === b.noteTone && - a.opts.badge === b.opts.badge && a.opts.aging === b.opts.aging && - a.opts.cue === b.opts.cue && a.opts.me === b.opts.me && a.opts.lastSeen === b.opts.lastSeen && a.opts.acked === b.opts.acked && diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index b0d34678..f200a299 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -186,16 +186,6 @@ a:hover { visibility: visible; } -.flag-qaing { - border-radius: 8px; - padding: 2px 7px; - font-size: 11px; - font-weight: 500; - background: var(--badge-purple-bg); - color: var(--badge-purple-fg); - flex: none; -} - /* ── Motion: state feedback only, exponential ease-out, one settle entrance ── */ @keyframes settle { from { diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index 2c1b9771..67ca9278 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -1,38 +1,19 @@ import type { DerivedPull } from '../model/status'; import { authorMove } from '../model/actions'; import type { PullData } from '../types'; -import { ago, pullKey } from '../format'; +import { pullKey } from '../format'; import { EmptyState } from '../components/bits'; -import { AnnotatedRow, Fold, Lane, RestGroup, Truncated } from '../components/Lane'; -import type { RowOptions } from '../components/Row'; +import { Fold, Lane, RestGroup, Truncated } from '../components/Lane'; +import { Row, type RowOptions } from '../components/Row'; import { ClosedRow } from '../components/ClosedRow'; /** * The author's tab: only PRs you own, split by whose move it is. The old * "Your PRs" lane mixed both answers; here "do this next" and "nudge this - * person" never share a section. + * person" never share a section. Each row's action/wait line comes from the + * shared rowNote (model/actions.ts), the same one every other lens renders. */ -function waitingOn(p: DerivedPull): string { - if (p.status === 'deploy_block' && p.deployBlockedBy.length) - return `held from deploy by ${p.deployBlockedBy.join(', ')}, ask before shipping`; - if (p.status === 'needs_recr' && p.recrBy.length) { - const wait = p.headPushedAt ? ` (fix pushed ${ago(p.headPushedAt)} ago)` : ''; - return `waiting on ${p.recrBy.join(', ')}’s re-stamp${wait}`; - } - if (p.status === 'needs_cr') { - if (p.crHave > 0) return `${p.crHave} of ${p.data.status.cr_req} CRs, open ${p.ageDays}d`; - return p.starved - ? `no CR for ${p.ageDays}d — go ask in channel` - : `no CR yet, open ${p.ageDays}d`; - } - if (p.status === 'needs_qa' && p.qaingBy) return `${p.qaingBy} is QAing now`; - if (p.status === 'needs_qa' && p.reqaBy.length) - return `waiting on ${p.reqaBy.join(', ')}’s re-QA`; - if (p.status === 'ci_pending') return 'waiting on CI'; - return 'waiting'; -} - export function MyWork({ pulls, closed, @@ -62,9 +43,7 @@ export function MyWork({ <> {move.map(p => ( - - {authorMove(p)} - + ))} {!move.length && (
@@ -74,9 +53,7 @@ export function MyWork({ {waiting.map(p => ( - - {waitingOn(p)} - + ))} {!waiting.length && (
diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index d20c6670..2677c997 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -5,8 +5,8 @@ import { authorMove, reviewerMove } from '../model/actions'; import { isFresh } from '../store'; import type { PullData } from '../types'; import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; -import { AnnotatedRow, Fold, FoldRows, Lane, RestGroup, Truncated } from '../components/Lane'; -import type { RowOptions } from '../components/Row'; +import { Fold, FoldRows, Lane, RestGroup, Truncated } from '../components/Lane'; +import { Row, type RowOptions } from '../components/Row'; import { ClosedRow } from '../components/ClosedRow'; /** @@ -124,10 +124,8 @@ export function Review({ {todo.length > 0 && ( - {todo.map(({ p, verb }) => ( - - {verb} - + {todo.map(({ p }) => ( + ))} @@ -139,12 +137,12 @@ export function Review({ cap={8} opts={opts} /> - + - + From 9bc2e7ca80a5299d474c432771f99fc193a41d42 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 15:21:49 -0700 Subject: [PATCH 065/381] Stop the stale pip from restating 're-stamp owed' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The amber sign-off pip's tooltip said 'invalidated by a later push — a re-stamp is owed', a third restatement of the fact the badge (Needs re-CR) and the row note (Re-stamp / waiting on X's re-stamp) already carry. Trim it to 'invalidated by a later push' and let the primary row text own the word. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/bits.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 94a51696..104d11ae 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -337,10 +337,7 @@ export function SigPips({ {s.data.active ? ( ) : ( - + )} ))} From 011842e5b34e4f599bea458961d7148a6bc1f6c1 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 15:25:24 -0700 Subject: [PATCH 066/381] Show the badge OR the imperative, never both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even after unifying the note, a your-move row still paired the badge and the verb for the same axis: "Needs re-CR" next to "Re-stamp". They're two names for one thing. So when it's your move, let the imperative be the signal — it leads the row (in the badge's slot) and the badge steps aside; when it's not your move, the badge names the state and the note only adds who/when. Also de-echo the wait notes that parroted their badge: "waiting on alice's re-stamp" → "waiting on alice", "dev-blocked by alice" → "feedback from alice", and similar for QA/CR/deploy, so the trailing text adds information the badge doesn't instead of restating it. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Row.tsx | 23 +++++++++++++++-------- frontend-v2/src/model/actions.ts | 18 ++++++++++-------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index ea9af57e..ea286866 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -191,18 +191,25 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { meta={ <> {fresh && } - + {/* your move: the imperative IS the signal, so it leads and the + badge (which would only echo it) steps aside. Otherwise the + badge names the state and any note just adds who/when. */} + {note?.tone === 'do' ? ( + + {note.text} + + ) : ( + + )} {pull.sizeKnown && ( )} - {note && ( - + {note?.tone === 'wait' && ( + {note.text} )} diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index 95c54001..d0161920 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -59,25 +59,27 @@ export function rowNote(p: DerivedPull, me: string): RowNote | null { return { text: verb, tone: 'do' }; } + // the badge already names the state; a wait note only adds who/when/why and + // must not echo the badge's words ("re-stamp", "dev-blocked", "QA") const wait = (text: string): RowNote => ({ text, tone: 'wait' }); switch (p.status) { case 'needs_recr': - return p.recrBy.length ? wait(`waiting on ${who(p.recrBy)}’s re-stamp${pushed}`) : null; + return p.recrBy.length ? wait(`waiting on ${who(p.recrBy)}${pushed}`) : null; case 'needs_qa': - if (p.qaingBy) return wait(`${who([p.qaingBy])} is QAing`); - if (p.reqaBy.length) return wait(`${who(p.reqaBy)}’s QA stamp needs redoing`); + if (p.qaingBy) return wait(`${who([p.qaingBy])} is testing it`); + if (p.reqaBy.length) return wait(`${who(p.reqaBy)}’s QA fell to a push`); return null; case 'needs_cr': if (p.crHave > 0) return wait(`${p.crHave} of ${d.status.cr_req} CRs`); - return p.starved ? wait(`no CR for ${p.ageDays}d`) : null; + return p.starved ? wait(`unreviewed for ${p.ageDays}d`) : null; case 'ci_red': - return p.ciFailing.length ? wait(`red: ${p.ciFailing.join(', ')}`) : null; + return p.ciFailing.length ? wait(`${p.ciFailing.join(', ')}`) : null; case 'dev_block': - return p.devBlockedBy.length ? wait(`dev-blocked by ${who(p.devBlockedBy)}`) : null; + return p.devBlockedBy.length ? wait(`feedback from ${who(p.devBlockedBy)}`) : null; case 'deploy_block': - return p.deployBlockedBy.length ? wait(`held by ${who(p.deployBlockedBy)}`) : null; + return p.deployBlockedBy.length ? wait(`ask ${who(p.deployBlockedBy)} first`) : null; case 'unmergeable': - return wait(p.conflict ? 'signed off, but conflicts' : 'lands with its parent'); + return wait(p.conflict ? 'conflicts, author rebases' : 'lands with its parent'); default: return null; } From 08884919bec77e2f64d264ec946811566bf38c8e Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 15:35:13 -0700 Subject: [PATCH 067/381] Give the your-move imperative a subtle brand pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The imperative led the row as bare bold text, which read quieter than the status-badge pill it replaces on other rows, so a your-move row looked less substantial than a waiting one. Add a badge-do pill — the badge's shape and size, a lighter brand-50 tint and slightly bolder weight — so the imperative carries the same visual weight as a status badge while still reading as an action, not a state. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Row.tsx | 5 +---- frontend-v2/src/styles.css | 8 ++++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index ea286866..e47ccdee 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -195,10 +195,7 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { badge (which would only echo it) steps aside. Otherwise the badge names the state and any note just adds who/when. */} {note?.tone === 'do' ? ( - + {note.text} ) : ( diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index f200a299..d63453e2 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -125,6 +125,14 @@ a:hover { } .badge-ready { background: var(--badge-green-bg); color: var(--badge-green-fg); } .badge-recr { background: var(--badge-brand-bg); color: var(--badge-brand-fg); } + /* the imperative for YOUR move: a status badge's shape, a lighter brand tint + and a touch bolder so it reads as "do this", not a state */ + .badge-do { + @apply rounded-lg px-[7px] py-[3px] text-xs font-semibold; + background: var(--brand-50); + color: var(--brand-700); + flex: none; + } .badge-qa { background: var(--badge-purple-bg); color: var(--badge-purple-fg); } .badge-cr { background: var(--badge-gray-bg); color: var(--badge-gray-fg); } .badge-blocked { background: var(--badge-amber-bg); color: var(--badge-amber-fg); } From fa131d06a38f127d085de1dca896eee215a61285 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 15:39:41 -0700 Subject: [PATCH 068/381] Port v1's desktop notifications for ready/re-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 alerted you when one of your PRs became ready to merge or a re-review (re-CR/re-QA) fell to you, via a navbar bell that also toggled a sound; v2 dropped it (a listed known gap). Add the engine back: useNotifications watches the whole board (not the current filter) and fires a desktop Notification for items that newly match since the last update, so a steady board stays quiet. The first payload after load only primes the baseline — it never alerts for the backlog already sitting there, which v1's version did. Two settings back it (notify, notifySound, both default off); the next commit adds the Settings toggles. The optional sound is a short synthesized chime rather than a shipped mp3, so v2 stays self-contained and CSP-safe. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 3 + frontend-v2/src/notifications.ts | 111 +++++++++++++++++++++++++++++++ frontend-v2/src/settings.ts | 6 ++ 3 files changed, 120 insertions(+) create mode 100644 frontend-v2/src/notifications.ts diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 84aeaca7..6467daa0 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -6,6 +6,7 @@ import { usePulldasher } from './store'; import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy'; import { loadSiteConfig, primeScope, useScope } from './prefs'; import { getSettings, useSettings } from './settings'; +import { useNotifications } from './notifications'; import { matchesQuery } from './model/query'; import { CRYO_KEY, repoHidden } from './model/visibility'; import { Legend } from './components/Legend'; @@ -153,6 +154,8 @@ export function App() { lastSeen, acked, } = usePulldasher(); + // desktop notifications watch the whole board, not the current filter + useNotifications(pulls, me); const [scope] = useScope(); // a v1 bookmark (?repo=…&author=…&cryo=1…) opens Classic configured the // same way; the chip below shows what it applied and dismisses it diff --git a/frontend-v2/src/notifications.ts b/frontend-v2/src/notifications.ts new file mode 100644 index 00000000..bfe6864b --- /dev/null +++ b/frontend-v2/src/notifications.ts @@ -0,0 +1,111 @@ +import { useEffect, useRef } from 'react'; +import { pullKey } from './format'; +import type { DerivedPull } from './model/status'; +import { getSettings } from './settings'; + +export const notificationsSupported = typeof window !== 'undefined' && 'Notification' in window; + +export function notifyPermission(): NotificationPermission { + return notificationsSupported ? Notification.permission : 'denied'; +} + +export async function requestNotifyPermission(): Promise { + if (!notificationsSupported) return 'denied'; + return Notification.requestPermission(); +} + +/** + * A short synthesized chime — no audio asset to ship or path to couple to the + * server layout, and CSP-safe. Best-effort: browsers block audio until the + * user has interacted with the page, in which case the notification still + * shows silently. + */ +function chime() { + try { + const Ctor = + window.AudioContext ?? + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctor) return; + const ctx = new Ctor(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = 880; + gain.gain.setValueAtTime(0.0001, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.2, ctx.currentTime + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.4); + osc.connect(gain).connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + 0.4); + osc.onended = () => void ctx.close(); + } catch { + // audio blocked: the desktop notification still fires, just silent + } +} + +interface Watch { + key: string; + match: (p: DerivedPull, me: string) => boolean; + message: (titles: string[]) => string; +} + +// The two transitions v1 alerted on, ported to v2's model. +const WATCHES: Watch[] = [ + { + key: 'ready', + match: (p, me) => p.data.user.login === me && p.status === 'ready', + message: t => + t.length === 1 ? `Ready to merge: ${t[0]}` : `${t.length} of your PRs are ready to merge`, + }, + { + key: 'rereview', + match: (p, me) => p.recrBy.includes(me) || p.reqaBy.includes(me), + message: t => + t.length === 1 ? `Re-review owed: ${t[0]}` : `${t.length} re-reviews are waiting on you`, + }, +]; + +/** + * Desktop notifications for your PRs becoming ready to merge and re-reviews + * falling to you. Fires only for items that newly match since the last update, + * so a steady board stays quiet — and the first payload after load only + * primes the baseline, it never alerts for the backlog already sitting there. + */ +export function useNotifications(pulls: DerivedPull[], me: string) { + const seen = useRef>>({}); + const primed = useRef(false); + + useEffect(() => { + const record = () => { + for (const w of WATCHES) + seen.current[w.key] = new Set( + pulls.filter(p => w.match(p, me)).map(p => pullKey(p.data)) + ); + }; + + const s = getSettings(); + const active = + notificationsSupported && s.notify && Notification.permission === 'granted' && !!me; + // keep the baseline current even while off, so toggling on doesn't flood + if (!active || !primed.current) { + record(); + primed.current = true; + return; + } + + for (const w of WATCHES) { + const now = pulls.filter(p => w.match(p, me)); + const prev = seen.current[w.key] ?? new Set(); + const fresh = now.filter(p => !prev.has(pullKey(p.data))); + if (fresh.length) { + try { + new Notification(w.message(fresh.map(p => p.data.title))); + } catch { + // notification construction can throw on some platforms; ignore + } + if (s.notifySound) chime(); + } + seen.current[w.key] = new Set(now.map(p => pullKey(p.data))); + } + }, [pulls, me]); +} diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index 91e80009..53251b79 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -28,6 +28,10 @@ export interface Settings { draftsMode: 'mine' | 'all'; /** your default for Cryogenic-Storage (parked) PRs */ showCryo: boolean; + /** desktop notifications when your PR is ready to merge / a re-review is owed */ + notify: boolean; + /** play a chime alongside those notifications */ + notifySound: boolean; } export const DEFAULT_SETTINGS: Settings = { @@ -40,6 +44,8 @@ export const DEFAULT_SETTINGS: Settings = { repoPrefs: {}, draftsMode: 'mine', showCryo: false, + notify: false, + notifySound: false, }; const store = createPersistentStore('pd2.settings', DEFAULT_SETTINGS); From b23a42bd9fd746de4ad6e1a5c893d488655ef1c5 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 15:40:55 -0700 Subject: [PATCH 069/381] Add the Notifications toggle to Settings Expose the ported desktop-notification feature: a Notifications group with an On/Off toggle (enabling it requests browser permission, and stays off if the permission is denied, with a hint pointing at the browser settings) and a separate Sound toggle for the chime. Both are gated behind Notification support, with a plain note when the browser lacks it. This puts the control in Settings where v2 keeps personal config, rather than v1's always-on navbar bell. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Settings.tsx | 62 +++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 0ac03770..26d0a937 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -1,6 +1,11 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { clearStoredPrefs } from '../storage'; +import { + notificationsSupported, + notifyPermission, + requestNotifyPermission, +} from '../notifications'; import { markAllSeen, refreshAll } from '../store'; import { type Settings as SettingsShape, setRepoPref, setSettings, useSettings } from '../settings'; import { Segmented } from './bits'; @@ -86,6 +91,18 @@ export function Settings({ const [seenNote, setSeenNote] = useState(false); const [refreshNote, setRefreshNote] = useState(''); const [armReset, setArmReset] = useState(false); + const [perm, setPerm] = useState(notifyPermission()); + + const toggleNotify = async (on: boolean) => { + if (!on) { + setSettings({ notify: false }); + return; + } + let p = notifyPermission(); + if (p === 'default') p = await requestNotifyPermission(); + setPerm(p); + setSettings({ notify: p === 'granted' }); + }; useEffect(() => { if (!open) return; @@ -235,6 +252,51 @@ export function Settings({ + + {notificationsSupported ? ( + <> + + void toggleNotify(v === 'on')} + /> + + {perm === 'denied' && ( + + Blocked in your browser settings — allow notifications for this + site to turn them on. + + )} + + set({ notifySound: v === 'on' })} + /> + + + ) : ( + + This browser doesn’t support desktop notifications. + + )} + + Date: Fri, 17 Jul 2026 15:41:23 -0700 Subject: [PATCH 070/381] Document desktop notifications in the README Move it out of the known-gaps list (it's ported now) into the feature list. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend-v2/README.md b/frontend-v2/README.md index 321eaa8b..dceddd5e 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -92,6 +92,12 @@ serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol Review (newest first), not a bar toggle — it collects the delta at the top the way "Yours to do" collects your actions, rather than filtering the board. A slim banner still counts the PRs merged or closed while you were away. +- **Desktop notifications.** Opt-in from Settings: a browser notification when + one of your PRs becomes ready to merge or a re-review (re-CR/re-QA) falls to + you, with an optional chime. It watches the whole board (not the current + filter) and only fires on new transitions — the first payload after load just + primes the baseline, so it never alerts for the backlog already there. + `src/notifications.ts`. - **Keyboard:** `/` filter, `j`/`k` walk rows, `Enter` opens, `c` copies the focused row's branch. @@ -140,4 +146,3 @@ lens `#lens=board` merged into Classic; old links redirect.) - **Stats are windowed, not historical.** Leaderboards and merge-time only see the open pool plus the server's 14-day closed window, so they read "lately", not "all time". Career totals would need the server to expose more history. -- **Desktop notifications** aren't ported yet. From ef6013588929aed0447b780447f71144ce871a1b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 15:47:20 -0700 Subject: [PATCH 071/381] Build notifications out to a full v2 feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass was a bare port. Audit turned up real gaps; close them: - Coverage: alert on every transition that actually lands on you, not just ready + re-review. New model/actions alertMove returns the nudge-worthy action (mergeable / CI failed / feedback / rebase / re-CR / re-QA) and excludes standing states (find-a-QA-er, drafting, self-claimed QA). Locked with actions.test.ts. - Clickable: each notification focuses the tab and opens its PR, and carries a per-PR tag so a newer state replaces the old alert instead of stacking. - Not noise: fire only while the tab is unfocused — when you're looking, the board already surfaces it. The baseline still updates every tick, so returning and leaving never replays what you saw. - Sound that actually plays: reuse one AudioContext unlocked by the toggle's click (a user gesture), so the chime isn't muted by autoplay policy when it fires in a backgrounded tab. Two-note chime instead of a single beep. - Verifiable: a 'send a test notification' button. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/README.md | 16 +- frontend-v2/src/components/Settings.tsx | 22 ++- frontend-v2/src/model/actions.test.ts | 57 +++++++ frontend-v2/src/model/actions.ts | 16 ++ frontend-v2/src/notifications.ts | 193 +++++++++++++++--------- 5 files changed, 228 insertions(+), 76 deletions(-) create mode 100644 frontend-v2/src/model/actions.test.ts diff --git a/frontend-v2/README.md b/frontend-v2/README.md index dceddd5e..512e99f9 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -92,11 +92,17 @@ serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol Review (newest first), not a bar toggle — it collects the delta at the top the way "Yours to do" collects your actions, rather than filtering the board. A slim banner still counts the PRs merged or closed while you were away. -- **Desktop notifications.** Opt-in from Settings: a browser notification when - one of your PRs becomes ready to merge or a re-review (re-CR/re-QA) falls to - you, with an optional chime. It watches the whole board (not the current - filter) and only fires on new transitions — the first payload after load just - primes the baseline, so it never alerts for the backlog already there. +- **Desktop notifications.** Opt-in from Settings: a browser notification for + every real transition that lands on you — your PR going mergeable, breaking + CI, getting feedback or needing a rebase, and re-CRs/re-QAs falling to you + (`model/actions.ts` `alertMove`). Each alert is clickable (focuses the tab and + opens the PR), replaces any older one for the same PR (`tag`), and carries an + optional two-note chime whose AudioContext is unlocked by the toggle's own + click so it isn't muted by autoplay policy when the tab is backgrounded. It + watches the whole board (not the current filter) and fires only while the tab + is unfocused — when you're looking, the board already shows the change. The + first payload after load just primes the baseline, so it never alerts for the + backlog already there, and a "send a test" button confirms the setup. `src/notifications.ts`. - **Keyboard:** `/` filter, `j`/`k` walk rows, `Enter` opens, `c` copies the focused row's branch. diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 26d0a937..aa8e2709 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -5,6 +5,8 @@ import { notificationsSupported, notifyPermission, requestNotifyPermission, + testNotification, + unlockSound, } from '../notifications'; import { markAllSeen, refreshAll } from '../store'; import { type Settings as SettingsShape, setRepoPref, setSettings, useSettings } from '../settings'; @@ -101,6 +103,9 @@ export function Settings({ let p = notifyPermission(); if (p === 'default') p = await requestNotifyPermission(); setPerm(p); + // this handler runs from the toggle click, so unlock audio now while we + // still have the gesture, in case Sound is already on + if (p === 'granted') unlockSound(); setSettings({ notify: p === 'granted' }); }; @@ -257,7 +262,7 @@ export function Settings({ <> set({ notifySound: v === 'on' })} + onChange={v => { + // a toggle click is the gesture that unlocks audio + if (v === 'on') unlockSound(); + set({ notifySound: v === 'on' }); + }} /> + {s.notify && perm === 'granted' && ( + + )} ) : ( diff --git a/frontend-v2/src/model/actions.test.ts b/frontend-v2/src/model/actions.test.ts new file mode 100644 index 00000000..09fd4d99 --- /dev/null +++ b/frontend-v2/src/model/actions.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import type { DerivedPull, Status } from './status'; +import { alertMove } from './actions'; + +/** A DerivedPull with only the fields the move functions read. */ +function dp(o: { + author?: string; + status: Status; + conflict?: boolean; + qaingBy?: string | null; + reqaBy?: string[]; + recrBy?: string[]; +}): DerivedPull { + return { + data: { user: { login: o.author ?? 'author' } }, + status: o.status, + conflict: o.conflict ?? false, + qaingBy: o.qaingBy ?? null, + reqaBy: o.reqaBy ?? [], + recrBy: o.recrBy ?? [], + } as unknown as DerivedPull; +} + +describe('alertMove — which transitions earn a desktop nudge', () => { + it('alerts the author on real transitions to their own PR', () => { + expect(alertMove(dp({ author: 'me', status: 'ready' }), 'me')).toBe('Merge it'); + expect(alertMove(dp({ author: 'me', status: 'ci_red' }), 'me')).toBe('Fix CI'); + expect(alertMove(dp({ author: 'me', status: 'dev_block' }), 'me')).toBe('Address feedback'); + expect(alertMove(dp({ author: 'me', status: 'unmergeable' }), 'me')).toBe('Rebase'); + }); + + it('does not alert on self-initiated / standing states', () => { + // "Find a QA-er" and "Finish the draft" are conditions, not events + expect(alertMove(dp({ author: 'me', status: 'needs_qa' }), 'me')).toBeNull(); + expect(alertMove(dp({ author: 'me', status: 'draft' }), 'me')).toBeNull(); + }); + + it('alerts a reviewer only for re-CR / re-QA that fell to them', () => { + expect(alertMove(dp({ author: 'a', status: 'needs_recr', recrBy: ['me'] }), 'me')).toBe( + 'Re-stamp' + ); + expect(alertMove(dp({ author: 'a', status: 'needs_qa', reqaBy: ['me'] }), 'me')).toBe( + 'Re-QA' + ); + // claiming QA yourself is not an incoming event + expect(alertMove(dp({ author: 'a', status: 'needs_qa', qaingBy: 'me' }), 'me')).toBeNull(); + // a fresh needs-CR isn't owed by anyone in particular + expect(alertMove(dp({ author: 'a', status: 'needs_cr' }), 'me')).toBeNull(); + }); + + it('does not alert on a PR you neither own nor owe a stamp on', () => { + expect(alertMove(dp({ author: 'other', status: 'ready' }), 'me')).toBeNull(); + expect( + alertMove(dp({ author: 'other', status: 'needs_recr', recrBy: ['x'] }), 'me') + ).toBeNull(); + }); +}); diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index d0161920..badb0521 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -29,6 +29,22 @@ export function reviewerMove(p: DerivedPull, me: string): string | null { return null; } +/** + * The subset of moves worth a desktop nudge: a real transition that just + * landed on you — your PR is mergeable / broke CI / got feedback / needs a + * rebase, or a re-CR/re-QA fell to you. Excludes self-initiated states + * (claiming QA, still drafting) and "go find someone" states, which aren't + * events so much as standing conditions. Returns the action label, or null. + */ +export function alertMove(p: DerivedPull, me: string): string | null { + if (p.data.user.login === me) { + const v = authorMove(p); + return v && v !== 'Find a QA-er' && v !== 'Finish the draft' ? v : null; + } + const v = reviewerMove(p, me); + return v === 'Re-stamp' || v === 'Re-QA' ? v : null; +} + /** 'do' = your move (brand, bold imperative); 'wait' = context on someone else's move. */ export interface RowNote { text: string; diff --git a/frontend-v2/src/notifications.ts b/frontend-v2/src/notifications.ts index bfe6864b..6f25ffd1 100644 --- a/frontend-v2/src/notifications.ts +++ b/frontend-v2/src/notifications.ts @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; -import { pullKey } from './format'; +import { githubUrl, pullKey, shortRepo } from './format'; +import { alertMove } from './model/actions'; import type { DerivedPull } from './model/status'; import { getSettings } from './settings'; @@ -14,98 +15,152 @@ export async function requestNotifyPermission(): Promise return Notification.requestPermission(); } -/** - * A short synthesized chime — no audio asset to ship or path to couple to the - * server layout, and CSP-safe. Best-effort: browsers block audio until the - * user has interacted with the page, in which case the notification still - * shows silently. - */ +// ── Sound ────────────────────────────────────────────────────────────────── +// Autoplay policy blocks audio until the page has had a user gesture, and a +// notification fires when the tab is in the background — exactly when a +// fresh, never-unlocked AudioContext would be muted. So keep one context, +// unlocked by the Settings toggle's click (a gesture), and reuse it. +let audioCtx: AudioContext | null = null; +function ctor(): typeof AudioContext | undefined { + return ( + window.AudioContext ?? + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + ); +} + +/** Call from a user gesture (the Sound toggle) so later chimes aren't muted. */ +export function unlockSound() { + try { + const C = ctor(); + if (!C) return; + audioCtx ??= new C(); + void audioCtx.resume(); + } catch { + // audio unavailable: notifications still fire silently + } +} + function chime() { try { - const Ctor = - window.AudioContext ?? - (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; - if (!Ctor) return; - const ctx = new Ctor(); - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = 'sine'; - osc.frequency.value = 880; - gain.gain.setValueAtTime(0.0001, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.2, ctx.currentTime + 0.01); - gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.4); - osc.connect(gain).connect(ctx.destination); - osc.start(); - osc.stop(ctx.currentTime + 0.4); - osc.onended = () => void ctx.close(); + const C = ctor(); + if (!C) return; + audioCtx ??= new C(); + const ctx = audioCtx; + void ctx.resume(); + const t0 = ctx.currentTime; + const note = (freq: number, start: number, dur: number) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0.0001, t0 + start); + gain.gain.exponentialRampToValueAtTime(0.18, t0 + start + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, t0 + start + dur); + osc.connect(gain).connect(ctx.destination); + osc.start(t0 + start); + osc.stop(t0 + start + dur); + }; + note(660, 0, 0.18); + note(990, 0.13, 0.26); + } catch { + // best-effort: a blocked chime never blocks the notification + } +} + +// ── Notifications ──────────────────────────────────────────────────────────── +// Friendlier titles than the terse in-board verbs. +const TITLE: Record = { + 'Merge it': 'Ready to merge', + 'Fix CI': 'CI failed', + 'Address feedback': 'Changes requested', + Rebase: 'Needs a rebase', + 'Re-stamp': 'Re-review owed', + 'Re-QA': 'Re-QA owed', +}; + +function fire(p: DerivedPull, action: string) { + try { + const notification = new Notification(TITLE[action] ?? action, { + body: `${p.data.title} · ${shortRepo(p.data.repo)} #${p.data.number}`, + // one live notification per PR: a newer state replaces the old one + tag: pullKey(p.data), + }); + notification.onclick = () => { + window.focus(); + window.open(githubUrl(p.data.repo, p.data.number), '_blank', 'noopener'); + notification.close(); + }; } catch { - // audio blocked: the desktop notification still fires, just silent + // construction can throw on some platforms; a failed alert is not fatal } } -interface Watch { - key: string; - match: (p: DerivedPull, me: string) => boolean; - message: (titles: string[]) => string; +/** Fire a sample notification so the user can confirm the setup works. */ +export function testNotification() { + if (!notificationsSupported || Notification.permission !== 'granted') return; + try { + const notification = new Notification('Pulldasher notifications are on', { + body: 'You’ll get a nudge here when a PR needs you.', + }); + notification.onclick = () => { + window.focus(); + notification.close(); + }; + } catch { + // ignore + } + if (getSettings().notifySound) chime(); } -// The two transitions v1 alerted on, ported to v2's model. -const WATCHES: Watch[] = [ - { - key: 'ready', - match: (p, me) => p.data.user.login === me && p.status === 'ready', - message: t => - t.length === 1 ? `Ready to merge: ${t[0]}` : `${t.length} of your PRs are ready to merge`, - }, - { - key: 'rereview', - match: (p, me) => p.recrBy.includes(me) || p.reqaBy.includes(me), - message: t => - t.length === 1 ? `Re-review owed: ${t[0]}` : `${t.length} re-reviews are waiting on you`, - }, -]; +/** At most this many individual alerts per update; a rare bigger burst collapses. */ +const MAX_PER_TICK = 5; /** - * Desktop notifications for your PRs becoming ready to merge and re-reviews - * falling to you. Fires only for items that newly match since the last update, - * so a steady board stays quiet — and the first payload after load only - * primes the baseline, it never alerts for the backlog already sitting there. + * Desktop notifications for every real transition that lands on you — your PR + * going mergeable, breaking CI, getting feedback or needing a rebase, and + * re-CRs / re-QAs falling to you (model/actions alertMove). Watches the whole + * board, not the current filter. + * + * Only fires while the window is unfocused: when you're looking, the board + * already surfaces the change, so a desktop alert would just be noise. The + * baseline is recorded on every update regardless, so returning to the tab and + * leaving again never replays what already happened, and the first payload + * after load only primes the baseline instead of alerting for the backlog. */ export function useNotifications(pulls: DerivedPull[], me: string) { - const seen = useRef>>({}); + // pull key → the action last seen for it, so a changed action re-alerts + const seen = useRef>(new Map()); const primed = useRef(false); useEffect(() => { - const record = () => { - for (const w of WATCHES) - seen.current[w.key] = new Set( - pulls.filter(p => w.match(p, me)).map(p => pullKey(p.data)) - ); - }; + const current = new Map(); + for (const p of pulls) { + const action = alertMove(p, me); + if (action) current.set(pullKey(p.data), action); + } const s = getSettings(); const active = notificationsSupported && s.notify && Notification.permission === 'granted' && !!me; - // keep the baseline current even while off, so toggling on doesn't flood - if (!active || !primed.current) { - record(); + + // record the baseline but stay silent while off, unprimed, or focused + if (!active || !primed.current || document.hasFocus()) { + seen.current = current; primed.current = true; return; } - for (const w of WATCHES) { - const now = pulls.filter(p => w.match(p, me)); - const prev = seen.current[w.key] ?? new Set(); - const fresh = now.filter(p => !prev.has(pullKey(p.data))); - if (fresh.length) { - try { - new Notification(w.message(fresh.map(p => p.data.title))); - } catch { - // notification construction can throw on some platforms; ignore - } - if (s.notifySound) chime(); + const byKey = new Map(pulls.map(p => [pullKey(p.data), p])); + let fired = 0; + for (const [key, action] of current) { + if (seen.current.get(key) === action) continue; // unchanged + const p = byKey.get(key); + if (p && fired < MAX_PER_TICK) { + fire(p, action); + fired++; } - seen.current[w.key] = new Set(now.map(p => pullKey(p.data))); } + if (fired > 0 && s.notifySound) chime(); + seen.current = current; }, [pulls, me]); } From 21ae96a5a12fc3226dda7424d7919e3341417f02 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 15:48:24 -0700 Subject: [PATCH 072/381] Unlock notification sound on first gesture too The Sound toggle unlocks the AudioContext on its click, but when notify was persisted on from a prior session that toggle is never touched, so the first chime could be autoplay-blocked. Add a one-time pointerdown/keydown backstop that resumes the context on the session's first interaction when sound is on. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/notifications.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend-v2/src/notifications.ts b/frontend-v2/src/notifications.ts index 6f25ffd1..b53a96a9 100644 --- a/frontend-v2/src/notifications.ts +++ b/frontend-v2/src/notifications.ts @@ -132,6 +132,22 @@ export function useNotifications(pulls: DerivedPull[], me: string) { const seen = useRef>(new Map()); const primed = useRef(false); + // Sound is normally unlocked by the Settings toggle's click, but if `notify` + // was persisted on from a previous session that toggle is never touched. + // Unlock on the first gesture of the session as a backstop. + useEffect(() => { + if (!notificationsSupported) return; + const unlock = () => { + if (getSettings().notifySound) unlockSound(); + }; + window.addEventListener('pointerdown', unlock, { once: true }); + window.addEventListener('keydown', unlock, { once: true }); + return () => { + window.removeEventListener('pointerdown', unlock); + window.removeEventListener('keydown', unlock); + }; + }, []); + useEffect(() => { const current = new Map(); for (const p of pulls) { From 7dc68c10af2f6eb25898a4ac7e13aa30c711a15d Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 16:02:33 -0700 Subject: [PATCH 073/381] Make the 'age turns amber' setting drive the aging lane, not just color Changing ageWarnDays in Settings recolored the age chip (AgeStamp already read the setting) but left the actual starved/aging logic pinned at the hardcoded STARVE_DAYS=4: the "Aging without full review" lane membership, the "open Nd" flag, and the "unreviewed for Nd" note all ignored the user's threshold. So the setting silently half-worked. Thread warnDays into derive() (defaulting to STARVE_DAYS for non-UI callers) so the starved flag uses it. The store passes getSettings().ageWarnDays, keys the derive cache on it so a change actually re-derives instead of returning the cached value, and subscribes to settings changes to re-publish immediately. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/status.ts | 7 +++++-- frontend-v2/src/settings.ts | 4 ++++ frontend-v2/src/store.ts | 29 +++++++++++++++++++---------- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index 80d50b34..477e7069 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -187,7 +187,10 @@ export const qaDone = (p: { qaHave: number; data: PullData }) => p.qaHave >= p.d export function derive( pull: PullData, spec: RepoSpec | undefined, - now: number = Date.now() / 1000 + now: number = Date.now() / 1000, + /** the age at which a CR-incomplete pull counts as starved (the user's + * "age turns amber" setting; defaults to the model's own threshold). */ + warnDays: number = STARVE_DAYS ): DerivedPull { const st = pull.status; const crBy = activeUsers(st.allCR); @@ -231,7 +234,7 @@ export function derive( // Rot is rot whether the pull has zero stamps, one of two, or a stale one // waiting on a re-stamp — the old `crHave === 0` cliff hid half-reviewed // pulls from the aging lane forever. - const starved = ['needs_cr', 'needs_recr'].includes(status) && !crMet && ageDays >= STARVE_DAYS; + const starved = ['needs_cr', 'needs_recr'].includes(status) && !crMet && ageDays >= warnDays; const signedOffAt = crMet && qaMet diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index 53251b79..d4f7ba56 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -70,3 +70,7 @@ export function setRepoPref(repo: string, pref: 'mute' | 'show' | null) { export function useSettings(): Settings { return store.useValue(); } + +/** Non-React subscription, for the store to re-derive when a setting that + * feeds the model (the aging threshold) changes. Returns an unsubscribe. */ +export const subscribeSettings = store.subscribe; diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts index 17c869c7..6f1de8da 100644 --- a/frontend-v2/src/store.ts +++ b/frontend-v2/src/store.ts @@ -1,7 +1,7 @@ import { useSyncExternalStore } from 'react'; import { backend, type ConnectionState } from './backend/socket'; import { derive, type DerivedPull } from './model/status'; -import { getSettings } from './settings'; +import { getSettings, subscribeSettings } from './settings'; import { readStorage, writeStorage } from './storage'; import type { PullData, RepoSpec } from './types'; @@ -108,23 +108,30 @@ let snapshot: Snapshot = { acked: new Set(acked), }; -// derive() is pure per (pull, spec): cache on reference identity so a -// pullChange for one pull doesn't rebuild 180 DerivedPull objects (and -// re-render 180 memoized rows). -const derived = new WeakMap(); -function deriveCached(pull: PullData, spec: RepoSpec | undefined): DerivedPull { +// derive() is pure per (pull, spec, warnDays): cache on reference identity so +// a pullChange for one pull doesn't rebuild 180 DerivedPull objects (and +// re-render 180 memoized rows). warnDays is in the key so changing the aging +// threshold in Settings actually re-derives (starved gates the aging lane). +const derived = new WeakMap< + PullData, + { spec: RepoSpec | undefined; warnDays: number; value: DerivedPull } +>(); +function deriveCached(pull: PullData, spec: RepoSpec | undefined, warnDays: number): DerivedPull { const hit = derived.get(pull); - if (hit && hit.spec === spec) return hit.value; - const value = derive(pull, spec); - derived.set(pull, { spec, value }); + if (hit && hit.spec === spec && hit.warnDays === warnDays) return hit.value; + const value = derive(pull, spec, Date.now() / 1000, warnDays); + derived.set(pull, { spec, warnDays, value }); return value; } function publish() { const specByName = new Map(repoSpecs.map(s => [s.name, s])); + const warnDays = getSettings().ageWarnDays; const all = [...raw.values()]; snapshot = { - pulls: all.filter(p => p.state === 'open').map(p => deriveCached(p, specByName.get(p.repo))), + pulls: all + .filter(p => p.state === 'open') + .map(p => deriveCached(p, specByName.get(p.repo), warnDays)), closed: all .filter(p => p.state === 'closed') .sort( @@ -185,6 +192,8 @@ function start() { connection = state; schedulePublish(); }); + // the aging threshold feeds derive(): re-derive when the user changes it + subscribeSettings(schedulePublish); // time-based derivations (iterating, "Nm ago") expire even on a quiet board setInterval(schedulePublish, 60_000); } From f1c2850574e9504ae61ee74948abd0479c58ed41 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 16:05:48 -0700 Subject: [PATCH 074/381] Give ci_pending / dev_block / unmergeable distinct badge colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three rendered the same amber, so 'Only CI left' (benign, nearly done) looked exactly as urgent as 'Dev blocked' (you owe feedback) and 'Cant merge' (conflict) — collapsing the very distinction the status vocabulary was built to draw. Split them: a new calm slate tone for the CI wait, amber kept for the action-owed dev block, and gray for the git-conflict rebase. Dots follow the badges. Adds badge-slate / --slate tokens in both themes. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/bits.tsx | 12 ++++++++---- frontend-v2/src/styles.css | 7 +++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 104d11ae..f38b602f 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -20,28 +20,32 @@ export const STATUS_LABEL: Record = { draft: 'Draft', }; +// ci_pending / dev_block / unmergeable used to share amber, which made +// "only CI left" (benign, nearly done) look as urgent as "dev blocked" (you +// owe feedback). Three distinct tones now: calm slate for the CI wait, amber +// for the action-owed dev block, gray for the git-conflict rebase. const STATUS_CLASS: Record = { ready: 'badge-ready', - ci_pending: 'badge-blocked', + ci_pending: 'badge-slate', needs_recr: 'badge-recr', needs_qa: 'badge-qa', needs_cr: 'badge-cr', dev_block: 'badge-blocked', deploy_block: 'badge-hold', - unmergeable: 'badge-blocked', + unmergeable: 'badge-hold', ci_red: 'badge-red', draft: 'badge-draft', }; export const STATUS_DOT: Record = { ready: 'var(--ok)', - ci_pending: 'var(--warn)', + ci_pending: 'var(--slate)', needs_recr: 'var(--brand)', needs_qa: 'var(--violet)', needs_cr: 'var(--ink-3)', dev_block: 'var(--warn)', deploy_block: 'var(--ink-3)', - unmergeable: 'var(--warn)', + unmergeable: 'var(--ink-3)', ci_red: 'var(--bad)', draft: 'var(--border)', }; diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index d63453e2..ffff6db2 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -31,6 +31,9 @@ --badge-brand-fg: var(--brand-900); --badge-gray-bg: #e5e7eb; --badge-gray-fg: #101828; + --badge-slate-bg: #dde3ea; + --badge-slate-fg: #334155; + --slate: #475569; /* text-grade in light mode: --ok/--warn also fill the 8px sign-off pips, the most decision-critical marks on the board, and must stay legible */ --ok: #15803d; @@ -70,6 +73,9 @@ --badge-brand-fg: #cce7ff; --badge-gray-bg: #364153; --badge-gray-fg: #e5e7eb; + --badge-slate-bg: #334155; + --badge-slate-fg: #dde7f2; + --slate: #94a3b8; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171; @@ -138,6 +144,7 @@ a:hover { .badge-blocked { background: var(--badge-amber-bg); color: var(--badge-amber-fg); } /* deploy hold: deliberate, not alarming — gray, not amber */ .badge-hold { background: var(--badge-gray-bg); color: var(--badge-gray-fg); } + .badge-slate { background: var(--badge-slate-bg); color: var(--badge-slate-fg); } .badge-red { background: var(--badge-red-bg); color: var(--badge-red-fg); } .badge-draft { background: none; color: var(--ink-3); border: 1px dashed var(--border); } From 5338c63a8b432d0c4f6cbf30a3f29e6921d46342 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 16:10:41 -0700 Subject: [PATCH 075/381] Make compact density actually compact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compact was one CSS rule that shaved 8px of row padding and nothing else, so it was barely distinguishable from comfortable. Finish the density that the data-density hook only pretended to implement: - CardShell gets a real compact layout: the two-line card (title above, meta below) collapses to a single line — avatar, a truncating title, then the meta chips and the right-anchored rail — with the avatar shrunk 22->16 and tighter padding. This is the structural change that actually changes rows-per-screen. - Row padding drops to 2px, the metric-rail gap and sign-off pips and weight meter shrink, and lane headers and section spacing tighten — all scoped under data-density=compact so the whole row shrinks together. - Lanes show ~50% more rows before folding in compact, since the rows are much shorter. Threaded as opts.compact from settings.density through RowOptions so it rides the existing row plumbing and the memo comparator. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 3 +- frontend-v2/src/components/Card.tsx | 58 +++++++++++++++++++---------- frontend-v2/src/components/Lane.tsx | 31 ++++++++++++--- frontend-v2/src/components/Row.tsx | 6 ++- frontend-v2/src/styles.css | 24 ++++++++++-- 5 files changed, 92 insertions(+), 30 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 6467daa0..2b0c780c 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -410,8 +410,9 @@ export function App() { onPerson, ageWarnDays: settings.ageWarnDays, ageRotDays: settings.ageRotDays, + compact: settings.density === 'compact', }), - [me, lastSeen, acked, onPerson, settings.ageWarnDays, settings.ageRotDays] + [me, lastSeen, acked, onPerson, settings.ageWarnDays, settings.ageRotDays, settings.density] ); const mineCount = humans.filter(p => p.data.user.login === me).length; diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index ac085500..eab0e045 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -2,17 +2,21 @@ import type { ReactNode } from 'react'; import { Avatar, PullTitleLink } from './bits'; /** - * The one row every lens renders: identity on top (avatar + full-width - * wrapping title), one meta line below (repo, context, flags, and a - * right-anchored metric rail). Giving the title its own line is what lets a - * long title wrap cleanly instead of fighting a dozen metadata chips, and - * what lets the whole thing reflow to a phone. The meta content is the - * caller's — only the shell (and its geometry) is shared, so the rail lands - * at the same x down the board in every lens. + * The one row every lens renders. Two densities of the same content: * - * The title link stretches over the whole card (`stretch`), so a click - * anywhere opens the PR; genuinely interactive children opt back out with - * `.pd-raise`. + * - comfortable: avatar + full-width wrapping title on top, one meta line + * below. Giving the title its own line lets a long title wrap cleanly + * instead of fighting a dozen metadata chips, and lets the card reflow to a + * phone. + * - compact: everything on a single line — avatar, a truncating title, then + * the meta chips and the right-anchored rail — with a smaller avatar and + * tighter padding, so ~40% more rows fit on screen. The title gives up + * wrapping (it ellipsizes) in exchange for the density. + * + * The meta content is the caller's; only the shell (and its geometry) is + * shared, so the rail lands at the same x down the board in every lens. The + * title link stretches over the whole card (`stretch`), so a click anywhere + * opens the PR; genuinely interactive children opt back out with `.pd-raise`. */ export function CardShell({ login, @@ -24,6 +28,7 @@ export function CardShell({ className = '', meta, stretch = true, + compact = false, }: { login: string; onPerson?: (login: string) => void; @@ -35,7 +40,30 @@ export function CardShell({ /** the whole meta line: badge, repo#number, context, flags, metric rail */ meta: ReactNode; stretch?: boolean; + compact?: boolean; }) { + const titleLink = ( + + ); + + if (compact) { + return ( +
+ + + + + + {titleLink} + + {meta} + +
+ ); + } + return (
- - - + {titleLink} {meta} diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index ab6f53a0..f89ce3f2 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -10,10 +10,22 @@ export function Rows({ children }: { children: ReactNode }) { } /** The shared lane/group header: title, optional subtitle, optional right-aligned count. */ -function GroupHeader({ title, sub, count }: { title: string; sub?: string; count?: number }) { +function GroupHeader({ + title, + sub, + count, + compact, +}: { + title: string; + sub?: string; + count?: number; + compact?: boolean; +}) { return ( -
-

{title}

+
+

+ {title} +

{sub && {sub}} {count != null && ( <> @@ -45,12 +57,19 @@ export function Lane({ children?: ReactNode; }) { if (!pulls.length && !children) return null; + // compact packs ~40% more rows per screen, so show more before folding + const shown = opts.compact ? Math.ceil(cap * 1.5) : cap; return ( -
- +
+ {children} - + {pulls.map(p => ( ))} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index e47ccdee..fce82b85 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -10,6 +10,8 @@ import { CardShell } from './Card'; export interface RowOptions { /** show the open-Nd flag on starved pulls */ aging?: boolean; + /** compact density: one-line rows, smaller avatar, tighter spacing */ + compact?: boolean; me: string; lastSeen: number; /** pull keys opened this session (their fresh dots are cleared) */ @@ -137,7 +139,7 @@ function MetricRail({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const d = pull.data; const me = opts.me; return ( - + ackPull(key)} + compact={opts.compact} className={`${flashOnce(key, !!fresh) ? 'row-fresh' : ''} transition-[background-color] duration-150 motion-reduce:transition-none`} meta={ <> @@ -244,6 +247,7 @@ export const Row = memo( (a, b) => a.pull === b.pull && a.opts.aging === b.opts.aging && + a.opts.compact === b.opts.compact && a.opts.me === b.opts.me && a.opts.lastSeen === b.opts.lastSeen && a.opts.acked === b.opts.acked && diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index ffff6db2..7fce1653 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -274,11 +274,29 @@ a:hover { background: transparent; } -/* compact density: pack more rows on screen by tightening the row gutter */ +/* compact density: the CardShell already collapses to one line with a smaller + avatar and tighter padding; these finish the job for the pieces it doesn't + own — the closed-row variant, the sign-off pips, the weight meter, and the + metric-rail gap — so the whole row shrinks together, not just the card. */ :root[data-density="compact"] .pd-row { - padding-top: 0.25rem; - padding-bottom: 0.25rem; + padding-top: 0.125rem; + padding-bottom: 0.125rem; } +:root[data-density="compact"] .pd-rail { + gap: 0.375rem; +} +:root[data-density="compact"] .pip { + width: 7px; + height: 7px; +} +:root[data-density="compact"] .wt { + height: 10px; +} +:root[data-density="compact"] .wt i:nth-child(1) { height: 3px; } +:root[data-density="compact"] .wt i:nth-child(2) { height: 5px; } +:root[data-density="compact"] .wt i:nth-child(3) { height: 6px; } +:root[data-density="compact"] .wt i:nth-child(4) { height: 8px; } +:root[data-density="compact"] .wt i:nth-child(5) { height: 10px; } /* settings slide-out from the right, with a fading scrim */ @keyframes panel-in { From 06be3060a2f61b723da2a0708d7c2569d8718553 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 16:13:08 -0700 Subject: [PATCH 076/381] Add a lane-length setting, including no cap Lane fold thresholds were hardcoded per view (6-15 rows). Add a Lane length setting (Board group: 10 / 25 / 50 / No cap, default 10) that overrides them uniformly, so a reviewer on a wide monitor can see a whole queue at once and someone who wants the page short can keep it tight. No cap (0) shows every row with no fold. Threaded as opts.laneCap through RowOptions; Lane falls back to its own default when the setting is unset, and the compact 1.5x multiplier still applies on top. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 12 +++++++++++- frontend-v2/src/components/Lane.tsx | 7 +++++-- frontend-v2/src/components/Row.tsx | 3 +++ frontend-v2/src/components/Settings.tsx | 16 ++++++++++++++++ frontend-v2/src/settings.ts | 3 +++ 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 2b0c780c..7a9bc8af 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -411,8 +411,18 @@ export function App() { ageWarnDays: settings.ageWarnDays, ageRotDays: settings.ageRotDays, compact: settings.density === 'compact', + laneCap: settings.laneCap, }), - [me, lastSeen, acked, onPerson, settings.ageWarnDays, settings.ageRotDays, settings.density] + [ + me, + lastSeen, + acked, + onPerson, + settings.ageWarnDays, + settings.ageRotDays, + settings.density, + settings.laneCap, + ] ); const mineCount = humans.filter(p => p.data.user.login === me).length; diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index f89ce3f2..049a85f2 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -57,8 +57,11 @@ export function Lane({ children?: ReactNode; }) { if (!pulls.length && !children) return null; - // compact packs ~40% more rows per screen, so show more before folding - const shown = opts.compact ? Math.ceil(cap * 1.5) : cap; + // the user's lane-length setting (0 = no cap) overrides the lane's default; + // compact packs ~50% more rows per screen, so show more before folding + const base = opts.laneCap ?? cap; + const shown = + base === 0 ? Number.POSITIVE_INFINITY : opts.compact ? Math.ceil(base * 1.5) : base; return (
set({ defaultLens })} /> + + set({ laneCap: Number(v) })} + /> + Date: Fri, 17 Jul 2026 16:19:21 -0700 Subject: [PATCH 077/381] Rethink the row flags: two tones, one hover popover, no pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conflicts / dependent / hold / mergeable? / CI / iterating / aging flags all rendered as identical amber pill badges with only a native tooltip, which did three things wrong: they wore primary-badge weight while being secondary annotations (competing with the status badge and the do/wait note), the uniform amber over-alarmed benign facts like iterating and CI, and a cryptic one-word pill isn't self-explanatory. Redesign per the product register (restrained color, secondary reads secondary): drop the pills for quiet colored text, split into two tones — amber for the ones you act on (conflicts, hold, external, aging), muted gray for neutral facts (stacked, merge?, CI, iterating) — and gather them into one hover Popover (like the CR/QA pips) that lists each active flag with a dot and a plain-English sentence, so meaning is one hover away instead of hidden in a title attribute. Clearer labels too: dependent -> stacked, mergeable? -> merge?. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Legend.tsx | 7 +- frontend-v2/src/components/Row.tsx | 173 +++++++++++++++++--------- frontend-v2/src/styles.css | 14 ++- 3 files changed, 129 insertions(+), 65 deletions(-) diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 598decf0..f127d5b7 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -81,8 +81,11 @@ export function Legend() { 'someone claimed QA by adding the QAing label on GitHub' )} {item( - iterating, - 'changed in the last 30 minutes, may still be moving' + + conflicts + iterating + , + 'row flags: amber = act on it (conflicts, hold, external, aging), gray = a neutral fact (stacked, CI, iterating). Hover them for the full meaning' )} {item( Dev blocked, diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 8e6bb05a..3604e34c 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -6,6 +6,7 @@ import { pullKey } from '../format'; import { ackPull, isFresh, refreshPull } from '../store'; import { AgeStamp, DiffSize, FreshTag, RepoRef, SigPips, StatusBadge, WeightMeter } from './bits'; import { CardShell } from './Card'; +import { Popover } from './Popover'; export interface RowOptions { /** show the open-Nd flag on starved pulls */ @@ -44,52 +45,122 @@ function flashOnce(key: string, fresh: boolean): boolean { return true; } -/** Small warning flags that ride along regardless of lane. */ -function WarnFlags({ pull }: { pull: DerivedPull }) { +interface Flag { + key: string; + /** 'warn' = act on it (amber); 'note' = a neutral fact (muted) */ + tone: 'warn' | 'note'; + label: string; + detail: string; +} + +/** + * The row's secondary annotations, gathered in one place: conflicts, stacked, + * holds, in-flight CI, iterating, aging. They're not the status (the badge is) + * and not your action (the note is), so they read as quiet colored labels, + * not badges — amber only for the ones you act on, muted gray for plain facts. + */ +function rowFlags(pull: DerivedPull, showIterating: boolean, aging: boolean): Flag[] { + const p = pull; + const flags: Flag[] = []; + if (p.conflict && p.status !== 'unmergeable') + flags.push({ + key: 'conflicts', + tone: 'warn', + label: 'conflicts', + detail: 'Merge conflicts with the base branch — the author needs to rebase.', + }); + if (p.deployBlockedBy.length > 0 && p.status !== 'deploy_block') + flags.push({ + key: 'hold', + tone: 'warn', + label: 'hold', + detail: `On deploy hold by ${p.deployBlockedBy.join(', ')}; don’t ship without asking.`, + }); + if (p.externalBlock) + flags.push({ + key: 'external', + tone: 'warn', + label: 'external', + detail: 'Blocked on something outside this repo.', + }); + if (aging) + flags.push({ + key: 'aging', + tone: 'warn', + label: `open ${p.ageDays}d`, + detail: `Open ${p.ageDays} days without full CR (${p.crHave} of ${p.data.status.cr_req}).`, + }); + if (p.dependent && p.status !== 'unmergeable') + flags.push({ + key: 'stacked', + tone: 'note', + label: 'stacked', + detail: `Based on ${p.data.base.ref}, not the main branch; it lands with its parent.`, + }); + if (p.mergeUnknown && p.status === 'ready') + flags.push({ + key: 'merge', + tone: 'note', + label: 'merge?', + detail: 'GitHub hasn’t confirmed this merges cleanly yet.', + }); + if (p.ci === 'pending' && p.status !== 'ci_pending') + flags.push({ key: 'ci', tone: 'note', label: 'CI…', detail: 'CI is still running.' }); + if (showIterating) + flags.push({ + key: 'iterating', + tone: 'note', + label: 'iterating', + detail: 'Changed in the last 30 minutes; it may still be moving, so hold off.', + }); + return flags; +} + +/** + * The flag cluster: the short labels inline for scanning, the plain-English + * meaning one hover away — the same drill-down the CR/QA pips use, so nothing + * on the row hides its meaning behind a native tooltip. + */ +function RowFlags({ flags }: { flags: Flag[] }) { + if (!flags.length) return null; return ( - <> - {pull.conflict && pull.status !== 'unmergeable' && ( - ( + )} - {pull.externalBlock && ( - - external + > + {flags.map(f => ( + + + + {f.label} {f.detail} + - )} - + ))} + ); } @@ -216,23 +287,7 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { {note.text} )} - {showIterating && ( - - iterating - - )} - {opts.aging && pull.starved && ( - - open {pull.ageDays}d - - )} - + } diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 7fce1653..59c815e3 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -169,10 +169,16 @@ a:hover { /* the slot you personally stamped: a dotted rule under the pips */ .pip-mine { border-bottom: 1px dotted var(--ink-3); padding-bottom: 2px; } - .flag-amber { - @apply rounded-lg px-1.5 py-0.5 text-[11px] font-medium; - background: var(--badge-amber-bg); - color: var(--badge-amber-fg); + /* row flags: quiet colored labels, not badges — amber for act-on-it, + muted ink for neutral facts. The cluster's hover popover carries meaning. */ + .flag-warn { + @apply text-[11px] font-medium; + color: var(--warn); + flex: none; + } + .flag-note { + @apply text-[11px] font-medium; + color: var(--ink-3); flex: none; } From d4eb6ca0a9065bb4ec2b71e6bcacb65ad41d0575 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 16:25:02 -0700 Subject: [PATCH 078/381] Reword the iterating and merge flags in plain language Two copy fixes on the row flags: - 'iterating' -> 'recent changes'. The jargon wasn't self-explanatory, and a vaguer label also stops the exact 30-minute threshold from reading as a precise promise, which is the real answer to 'should the window be a setting' (no: soften the label instead of exposing the constant). - 'merge?' -> 'merge check'. A question mark in a status label reads as uncertainty about our own data rather than about the PR; say what's happening (GitHub is verifying the merge) instead of asking. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Legend.tsx | 4 ++-- frontend-v2/src/components/Row.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index f127d5b7..52327d1b 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -83,9 +83,9 @@ export function Legend() { {item( conflicts - iterating + recent changes , - 'row flags: amber = act on it (conflicts, hold, external, aging), gray = a neutral fact (stacked, CI, iterating). Hover them for the full meaning' + 'row flags: amber = act on it (conflicts, hold, external, aging), gray = a neutral fact (stacked, CI, recent changes). Hover them for the full meaning' )} {item( Dev blocked, diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 3604e34c..78b6b353 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -101,7 +101,7 @@ function rowFlags(pull: DerivedPull, showIterating: boolean, aging: boolean): Fl flags.push({ key: 'merge', tone: 'note', - label: 'merge?', + label: 'merge check', detail: 'GitHub hasn’t confirmed this merges cleanly yet.', }); if (p.ci === 'pending' && p.status !== 'ci_pending') @@ -110,8 +110,8 @@ function rowFlags(pull: DerivedPull, showIterating: boolean, aging: boolean): Fl flags.push({ key: 'iterating', tone: 'note', - label: 'iterating', - detail: 'Changed in the last 30 minutes; it may still be moving, so hold off.', + label: 'recent changes', + detail: 'Pushed in the last half hour; it may still be moving, so hold off.', }); return flags; } From 895f3de60c3c872c1a21cb01916126f830c013af Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 16:26:48 -0700 Subject: [PATCH 079/381] Show the actual push time in the recent-changes flag 'Pushed in the last half hour' was vaguer than the data we have. The flag only fires when isIterating is true, and DerivedPull carries the push clock it keys on, so show the real elapsed time: 'Pushed 12 min ago'. The label stays the plain 'recent changes'; the hover detail gets the precise minutes. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Row.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 78b6b353..e95350d2 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -2,7 +2,7 @@ import { memo, useState } from 'react'; import type { DerivedPull } from '../model/status'; import { isIterating } from '../model/status'; import { rowNote } from '../model/actions'; -import { pullKey } from '../format'; +import { ago, pullKey } from '../format'; import { ackPull, isFresh, refreshPull } from '../store'; import { AgeStamp, DiffSize, FreshTag, RepoRef, SigPips, StatusBadge, WeightMeter } from './bits'; import { CardShell } from './Card'; @@ -106,13 +106,15 @@ function rowFlags(pull: DerivedPull, showIterating: boolean, aging: boolean): Fl }); if (p.ci === 'pending' && p.status !== 'ci_pending') flags.push({ key: 'ci', tone: 'note', label: 'CI…', detail: 'CI is still running.' }); - if (showIterating) + if (showIterating) { + const pushedAt = p.headPushedAt ?? Date.parse(p.data.updated_at) / 1000; flags.push({ key: 'iterating', tone: 'note', label: 'recent changes', - detail: 'Pushed in the last half hour; it may still be moving, so hold off.', + detail: `Pushed ${ago(pushedAt)} ago; it may still be moving, so hold off.`, }); + } return flags; } From b0f32223589e2c9a04476cd48fcb635cfb4557d8 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 16:33:50 -0700 Subject: [PATCH 080/381] Say "deploy block", not "hold" "Hold" was invented dashboard jargon that nobody on the team uses; the actual term for a done-but-deliberately-unshipped PR is a deploy block. Rename it everywhere it surfaces: the status badge, the row flag, the Review lane, and the legend, so the board speaks the team's vocabulary. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Legend.tsx | 6 +++--- frontend-v2/src/components/Row.tsx | 6 +++--- frontend-v2/src/components/bits.tsx | 2 +- frontend-v2/src/views/Review.tsx | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 52327d1b..181443a6 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -85,15 +85,15 @@ export function Legend() { conflicts recent changes , - 'row flags: amber = act on it (conflicts, hold, external, aging), gray = a neutral fact (stacked, CI, recent changes). Hover them for the full meaning' + 'row flags: amber = act on it (conflicts, deploy block, external, aging), gray = a neutral fact (stacked, CI, recent changes). Hover them for the full meaning' )} {item( Dev blocked, 'a reviewer requested changes: the author’s move' )} {item( - Deploy hold, - 'done, deliberately held from shipping: ask the holder' + Deploy block, + 'done, deliberately kept from shipping: ask who blocked it' )} {item( Can't merge, diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index e95350d2..3a881b7a 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -71,10 +71,10 @@ function rowFlags(pull: DerivedPull, showIterating: boolean, aging: boolean): Fl }); if (p.deployBlockedBy.length > 0 && p.status !== 'deploy_block') flags.push({ - key: 'hold', + key: 'deploy-block', tone: 'warn', - label: 'hold', - detail: `On deploy hold by ${p.deployBlockedBy.join(', ')}; don’t ship without asking.`, + label: 'deploy block', + detail: `${p.deployBlockedBy.join(', ')} put a deploy block on it; don’t ship without asking.`, }); if (p.externalBlock) flags.push({ diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index f38b602f..6c506c8c 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -14,7 +14,7 @@ export const STATUS_LABEL: Record = { // the old "Blocked" wore one badge for three opposite situations; each // now says whose move it is dev_block: 'Dev blocked', - deploy_block: 'Deploy hold', + deploy_block: 'Deploy block', unmergeable: "Can't merge", ci_red: 'CI red', draft: 'Draft', diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 2677c997..ad7c2280 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -174,8 +174,8 @@ export function Review({ From c6316204c7bcf18decf9128e91215dba10cea5b0 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 16:40:31 -0700 Subject: [PATCH 081/381] Surface "Find a QA-er" as a home to-do for self-review teams The home "Yours to do" lane excluded getting QA on the theory that "Find a QA-er" is planning, not minutes. That assumes CR is the gate and QA-hunting is a soft afterthought, which is false where the team self-reviews: with no separate CR gate, lining up QA is the daily stall and a real do-now action. It isn't universal though, so gate it behind a new self-review setting (default on for iFixit's workflow) instead of hardcoding either behavior. When on, ownVerb lets "Find a QA-er" into the todo lane and it sorts last in MOVE_RANK; off preserves today's behavior (getting QA lives only in My work, which already lists it). The row already renders the "Find a QA-er" imperative via rowNote, so no card change was needed. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Settings.tsx | 14 ++++++++++++++ frontend-v2/src/settings.ts | 5 +++++ frontend-v2/src/views/Review.tsx | 19 +++++++++++++------ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 37489b98..bdee9ad2 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -201,6 +201,20 @@ export function Settings({ + + set({ selfReview: v === 'on' })} + /> + p.data.user.login !== me); // 1. Yours to do: strictly your verbs. The earlier "Act now" lesson still @@ -41,11 +43,12 @@ export function Review({ 'Fix CI', 'Address feedback', 'Rebase', + 'Find a QA-er', ]; const todo = pulls .map(p => ({ p, - verb: p.data.user.login === me ? ownVerb(p) : reviewerMove(p, me), + verb: p.data.user.login === me ? ownVerb(p, selfReview) : reviewerMove(p, me), })) .filter((x): x is { p: DerivedPull; verb: string } => x.verb !== null) .sort( @@ -235,10 +238,14 @@ export function Review({ ); } -// Your own pulls contribute only their do-it-now verbs to the home lane; -// "Find a QA-er" and "Finish the draft" stay in My work — they're planning, -// not minutes. -function ownVerb(p: DerivedPull): string | null { +// Your own pulls contribute only their do-it-now verbs to the home lane. +// "Finish the draft" always stays in My work (planning, not minutes). Getting +// QA is different: when the team self-reviews, CR isn't the gate and lining up +// QA is the daily stall, so "Find a QA-er" graduates to a home to-do. +function ownVerb(p: DerivedPull, selfReview: boolean): string | null { const verb = authorMove(p); - return verb && ['Merge it', 'Fix CI', 'Address feedback', 'Rebase'].includes(verb) ? verb : null; + if (!verb) return null; + const doNow = ['Merge it', 'Fix CI', 'Address feedback', 'Rebase']; + if (selfReview) doNow.push('Find a QA-er'); + return doNow.includes(verb) ? verb : null; } From cde3cc913f1cc28490a122571778f5fe2252c323 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 18:46:03 -0700 Subject: [PATCH 082/381] Ease sign-off pips when a stamp lands or goes stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pip meter is the board's most decision-critical state change (a CR/QA stamp arriving, or a push invalidating one), and it was the least animated: pips snapped between filled, amber, and hollow with no feedback. They were also keyed by fill kind, so a pip flipping off→on unmounted the hollow node and mounted a fresh filled one, which a CSS transition can't catch. Key each pip by its fixed slot instead, so the same node persists and changes only its class. A background/box-shadow transition then eases the flip in place. Transitions don't fire on mount, so the initial board paints instantly and only live flips animate. Reduced-motion collapses it via the global media query. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/bits.tsx | 17 +++++++++-------- frontend-v2/src/styles.css | 3 ++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 6c506c8c..c31a6f13 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -251,14 +251,15 @@ export function Pips({ mine || owedByMe ? 'pip-mine' : '' }`} > - {Array.from({ length: on }, (_, i) => ( - - ))} - {Array.from({ length: stale }, (_, i) => ( - - ))} - {Array.from({ length: off }, (_, i) => ( - + {/* keyed by fixed slot, not by fill kind, so a pip that flips + off→on (stamp lands) or on→stale (a push invalidates it) is + the same node easing its color, not a fresh mount */} + {[ + ...Array.from({ length: on }, () => 'on'), + ...Array.from({ length: stale }, () => 'stale'), + ...Array.from({ length: off }, () => 'off'), + ].map((kind, i) => ( + ))} )} diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 59c815e3..2bf184e3 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -162,7 +162,8 @@ a:hover { /* sign-off pips: one square per required stamp. Filled = a live stamp, amber = a stamp a push invalidated (a re-stamp is owed), hollow = still needed. This is the whole ledger vocabulary — no ✓, no ⊘. */ - .pip { width: 8px; height: 8px; border-radius: 2px; flex: none; } + .pip { width: 8px; height: 8px; border-radius: 2px; flex: none; + transition: background-color 240ms var(--ease-quint), box-shadow 240ms var(--ease-quint); } .pip-on { background: var(--ok); } .pip-stale { background: var(--warn); } .pip-off { background: transparent; box-shadow: inset 0 0 0 1.5px var(--border); } From e88c4b89dae12d8bcb6ddee912c27806ef5be582 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 18:46:49 -0700 Subject: [PATCH 083/381] Add press, copied-pop, and refresh-spin to row actions The two hover actions on a row lacked the feedback every other button in the app gets. Neither the copy nor the refresh button carried the house .pressable press-scale, the copy button hard-swapped its icon for the word "copied" with no transition, and refresh gave no sign it fired. Wrap both in .pressable, pop the "copied" label in with a quick scale (chip-in), and spin the refresh icon one turn while the re-fetch is in flight. All three collapse under the global reduced-motion query. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Row.tsx | 21 ++++++++++++++++----- frontend-v2/src/styles.css | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 3a881b7a..e2a9c4b3 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -169,13 +169,14 @@ function RowFlags({ flags }: { flags: Flag[] }) { /** Hover/focus actions: copy the branch name, re-fetch from GitHub. */ function RowActions({ pull }: { pull: DerivedPull }) { const [copied, setCopied] = useState(false); + const [spinning, setSpinning] = useState(false); return ( diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 2bf184e3..dd709533 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -359,6 +359,26 @@ a:hover { animation: draw-check 500ms var(--ease-quint) 200ms backwards; } +/* one-shot micro-interactions: a chip popping in, an icon spinning once */ +@keyframes chip-in { + from { + opacity: 0; + scale: 0.6; + } +} +.chip-in { + animation: chip-in 160ms var(--ease-quint); +} + +@keyframes spin-once { + to { + rotate: 360deg; + } +} +.spin-once { + animation: spin-once 600ms var(--ease-quart); +} + @keyframes nod { 50% { scale: 1.25; From f61b0349488f4289ffef50480e65fe612a8d9ca2 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 18:47:39 -0700 Subject: [PATCH 084/381] Stagger the "+N more" reveal and pop the fresh chip in Two small reveals were hard jumps. Expanding a "+N more" fold dropped the rest of the rows in at once, and the new/updated FreshTag appeared with no entrance even though it marks the one thing on the row you're meant to notice. Reveal the extra rows with the same settle stagger as first paint, gated to the click that opens the fold (a fold already open from a lens revisit this session stays instant). Wrap them in a display:contents element so the stagger doesn't disturb the row flow. Pop the FreshTag in with the chip-in scale. Both collapse under reduced motion. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Lane.tsx | 21 ++++++++++++++++++--- frontend-v2/src/components/bits.tsx | 2 +- frontend-v2/src/styles.css | 10 ++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index 049a85f2..197e115f 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -116,15 +116,30 @@ export function Truncated({ id?: string; }) { const [expanded, setExpanded] = useState(id ? expandedIds.has(id) : false); + // don't stagger a fold that was already open on mount (a lens revisit this + // session): the reveal animation is earned only on the click that opens it + const [justExpanded, setJustExpanded] = useState(false); const expand = () => { setExpanded(true); + setJustExpanded(true); if (id) expandedIds.add(id); }; - const shown = expanded ? children : children.slice(0, cap); - const more = children.length - shown.length; + const base = children.slice(0, cap); + const extra = expanded ? children.slice(cap) : []; + const more = children.length - base.length - extra.length; return ( <> - {shown} + {base} + {extra.length > 0 && + (justExpanded ? ( + // display:contents keeps the rows in the container's flow while + // the wrapper only carries the stagger +
+ {extra} +
+ ) : ( + extra + ))} {more > 0 && (
)} {initialized && lens === 'review' && ( - + )} {initialized && lens === 'mine' && ( diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index fcdcb3a6..089fabb0 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -140,7 +140,10 @@ export function Review({ <> {todo.length > 0 && ( - + {todo.map(({ p }) => ( ))} From 18f521a03beee6b96466362c5d76d24e5b63bd8a Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 19:10:04 -0700 Subject: [PATCH 087/381] Split the review queue by your primary repos The review queue was one flat cross-repo wall: with ~60 PRs awaiting first CR across 10+ repos, every reviewer saw a near-identical list, most of it in repos they never touch (a web dev shown firmware and ops PRs at the same weight as their own stack). Repo relevance is per-person, so the queue should be too. Add a per-user primaryRepos setting. The Review queue now leads with your primary repos; the rest fold into a collapsed "to review in other repos" at the top of the rest group, reachable but out of the way. Until you set any repos explicitly, infer them from where you've authored or stamped on the current board, so it personalizes with zero setup; if even that is empty, fall back to one flat queue (no behavior change). Starvation stays cross-repo on purpose: the aging-PR backstop is everyone's job, so it shouldn't be filtered to your repos. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/settings.ts | 13 +++++++++++ frontend-v2/src/views/Review.tsx | 39 +++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index ff59283a..bfad5d76 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -38,6 +38,11 @@ export interface Settings { * real stall: surface "Find a QA-er" on your own PR as a home to-do, not a * My-work afterthought. Off leaves getting QA in My work only. */ selfReview: boolean; + /** the repos you actually review, so the review queue leads with them and + * folds the rest away. Repo relevance is per-person (a web dev and a + * firmware dev share a monorepo but little else). Empty = infer from the + * repos where you've authored or stamped on the current board. */ + primaryRepos: string[]; } export const DEFAULT_SETTINGS: Settings = { @@ -54,6 +59,7 @@ export const DEFAULT_SETTINGS: Settings = { notifySound: false, laneCap: 10, selfReview: true, + primaryRepos: [], }; const store = createPersistentStore('pd2.settings', DEFAULT_SETTINGS); @@ -75,6 +81,13 @@ export function setRepoPref(repo: string, pref: 'mute' | 'show' | null) { setSettings({ repoPrefs: next }); } +/** Add or remove a repo from your primary (actively-reviewed) set. */ +export function togglePrimaryRepo(repo: string, primary: boolean) { + const cur = store.get().primaryRepos; + const next = primary ? [...new Set([...cur, repo])] : cur.filter(r => r !== repo); + setSettings({ primaryRepos: next }); +} + export function useSettings(): Settings { return store.useValue(); } diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 089fabb0..12d8b3aa 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -29,9 +29,23 @@ export function Review({ opts: RowOptions; }) { const me = opts.me; - const { selfReview } = useSettings(); + const { selfReview, primaryRepos } = useSettings(); const others = pulls.filter(p => p.data.user.login !== me); + // Repo relevance is per-person: a web dev and a firmware dev share the + // monorepo but little else. Your primary repos are the ones you set, or — + // until you set any — the ones you're demonstrably in (authored or stamped + // on the current board). An empty set means we can't tell, so treat every + // repo as primary and fall back to one flat queue. + const primarySet = primaryRepos.length + ? new Set(primaryRepos) + : new Set( + pulls + .filter(p => p.data.user.login === me || p.crBy.includes(me) || p.qaBy.includes(me)) + .map(p => p.data.repo) + ); + const isPrimaryRepo = (repo: string) => primarySet.size === 0 || primarySet.has(repo); + // 1. Yours to do: strictly your verbs. The earlier "Act now" lesson still // binds — padding this with other people's jobs made it noise — but // your own merge button is your job, whichever tab you're on. @@ -69,7 +83,12 @@ export function Review({ (p.status === 'needs_cr' || (p.status === 'needs_recr' && !p.recrBy.includes(me))) ); const aged = crPool.filter(p => p.starved).sort((a, b) => b.starveScore - a.starveScore); - const queue = crSort(crPool.filter(p => !p.starved)); + // your review queue leads with your repos; everything else folds into "other + // repos" so it's reachable but not in the way. Starvation stays cross-repo + // (below) — the fairness backstop is deliberately everyone's job. + const reviewable = crSort(crPool.filter(p => !p.starved)); + const queue = reviewable.filter(p => isPrimaryRepo(p.data.repo)); + const queueOther = reviewable.filter(p => !isPrimaryRepo(p.data.repo)); // Ready-to-merge is the author's button, not the reviewer's job: a count // in the rest group, not a lane at the top. @@ -157,7 +176,13 @@ export function Review({ cap={8} opts={opts} /> - + + + + Date: Fri, 17 Jul 2026 19:11:22 -0700 Subject: [PATCH 088/381] Add a star toggle to pick your primary repos The queue split reads primaryRepos, but nothing let you set it. Add a star per shown repo in the Settings repo manager: star the ones you review and your queue leads with them, the rest fold into "other repos." An inline note explains the default (inferred from your activity) until you star any, so the control teaches what the split does. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/RepoManager.tsx | 29 ++++++++++++++++++++++ frontend-v2/src/components/Settings.tsx | 10 +++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/frontend-v2/src/components/RepoManager.tsx b/frontend-v2/src/components/RepoManager.tsx index 39dac4cf..171f74fc 100644 --- a/frontend-v2/src/components/RepoManager.tsx +++ b/frontend-v2/src/components/RepoManager.tsx @@ -6,6 +6,8 @@ export function RepoManagerGroup({ orgHidden, prefs, onRepoPref, + primaryRepos, + onPrimary, }: { /** every known repo with its open-PR count, sorted however the caller likes */ repos: { name: string; count: number }[]; @@ -15,7 +17,12 @@ export function RepoManagerGroup({ prefs: Record; /** set or clear one repo's override; null follows the org default */ onRepoPref: (repo: string, pref: 'mute' | 'show' | null) => void; + /** the repos you actively review (lead your review queue); empty = inferred */ + primaryRepos: string[]; + /** add/remove a repo from your primary set */ + onPrimary: (repo: string, primary: boolean) => void; }) { + const primary = new Set(primaryRepos); const [query, setQuery] = useState(''); const needle = query.trim().toLowerCase(); @@ -93,10 +100,32 @@ export function RepoManagerGroup({
On your board ({shown.length})
+
+ {primary.size > 0 + ? 'Starred repos lead your review queue; the rest fold into “other repos.”' + : 'Star the repos you review to lead your queue with them. Until you do, they’re inferred from where you’ve authored or stamped.'} +
{shown.map(repo => { const isUserRevealed = orgHidden.has(repo.name) && prefs[repo.name] === 'show'; + const isPrimary = primary.has(repo.name); return (
+ {repo.name} {isUserRevealed && ( org-hidden · showing diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index bdee9ad2..f13e64ee 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -9,7 +9,13 @@ import { unlockSound, } from '../notifications'; import { markAllSeen, refreshAll } from '../store'; -import { type Settings as SettingsShape, setRepoPref, setSettings, useSettings } from '../settings'; +import { + type Settings as SettingsShape, + setRepoPref, + setSettings, + togglePrimaryRepo, + useSettings, +} from '../settings'; import { Segmented } from './bits'; import { RepoManagerGroup } from './RepoManager'; @@ -350,6 +356,8 @@ export function Settings({ orgHidden={orgHidden} prefs={s.repoPrefs} onRepoPref={setRepoPref} + primaryRepos={s.primaryRepos} + onPrimary={togglePrimaryRepo} /> From d7ec1f76621ec63815cd2ff3ae2dea4364a00ff9 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 19:33:36 -0700 Subject: [PATCH 089/381] Let a weight label override the diff-size heuristic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org is adding a GitHub Action (iFixit/ifixit#63258) that stamps a deterministic size: XS|S|M|L|XL label on every PR from weighted, per-file churn, and lets a maintainer pin it. That label is a better review-effort signal than the board's own additions+deletions guess, and it's the one a human can override; the board should defer to it. Add a weightLabels map to config.json (label title → bucket) so the label strings live in config, not code, and a deploy that renames or disables the labels needs no rebuild. In derive(), a matching label takes its bucket and also counts as a known size for the sort; with no match (repos the labeller doesn't run in, or a PR it hasn't reached) the diff-size heuristic stands unchanged. reviewWeight() itself is untouched — the override is a layer over it, keyed through the derive cache so weights re-resolve when the config loads. Not the difficulty field: that v1 signal doesn't carry the size buckets this scheme needs. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/public/config.example.json | 9 ++++- frontend-v2/src/app.tsx | 3 +- frontend-v2/src/model/status.test.ts | 56 +++++++++++++++++++++++++- frontend-v2/src/model/status.ts | 38 ++++++++++++++--- frontend-v2/src/prefs.ts | 27 ++++++++++++- frontend-v2/src/store.ts | 29 ++++++++++--- 6 files changed, 147 insertions(+), 15 deletions(-) diff --git a/frontend-v2/public/config.example.json b/frontend-v2/public/config.example.json index f3512fdd..734c1e8d 100644 --- a/frontend-v2/public/config.example.json +++ b/frontend-v2/public/config.example.json @@ -3,5 +3,12 @@ { "team": "frontend", "members": ["alice", "bob"] }, { "team": "backend", "members": ["carol"] } ], - "bots": ["renovate-bot"] + "bots": ["renovate-bot"], + "weightLabels": { + "size: XS": "XS", + "size: S": "S", + "size: M": "M", + "size: L": "L", + "size: XL": "XL" + } } diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index d3bd481d..f1acbf51 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { ago, n, shortRepo } from './format'; import type { DerivedPull } from './model/status'; import type { Team } from './types'; -import { usePulldasher } from './store'; +import { setWeightLabels, usePulldasher } from './store'; import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy'; import { loadSiteConfig, primeScope, useScope } from './prefs'; import { getSettings, useSettings } from './settings'; @@ -202,6 +202,7 @@ export function App() { void loadSiteConfig().then(c => { setExtraBots(new Set(c.bots)); setTeams(c.teams); + setWeightLabels(c.weightLabels); }); }, []); // View changes (lens, person, team) earn a history entry so the back diff --git a/frontend-v2/src/model/status.test.ts b/frontend-v2/src/model/status.test.ts index 8dafd9f9..6e85aea0 100644 --- a/frontend-v2/src/model/status.test.ts +++ b/frontend-v2/src/model/status.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest'; import type { PullData, Signature, SignatureType } from '../types'; -import { ciVerdict, derive, isIterating, reviewWeight } from './status'; +import { + ciVerdict, + derive, + isIterating, + reviewWeight, + STARVE_DAYS, + type Weight, + weightFromLabels, +} from './status'; const NOW = 1_800_000_000; @@ -354,3 +362,49 @@ describe('starvation and weight', () => { expect(reviewWeight(pull({ additions: 5000, deletions: 0 }))).toBe('XL'); }); }); + +describe('weight labels override the diff-size heuristic', () => { + const wl = (title: string) => ({ + title, + number: 1, + repo: 'iFixit/ifixit', + user: 'pr-weight-bot', + created_at: '2026-01-01T00:00:00Z', + }); + const MAP = new Map([ + ['size: XS', 'XS'], + ['size: S', 'S'], + ['size: M', 'M'], + ['size: L', 'L'], + ['size: XL', 'XL'], + ]); + + it('a matching label wins over the diff-size guess', () => { + const p = pull({ additions: 3, deletions: 1, labels: [wl('size: L')] }); + expect(reviewWeight(p)).toBe('XS'); // heuristic alone + expect(derive(p, undefined, NOW, STARVE_DAYS, MAP).weight).toBe('L'); + }); + + it('falls back to the heuristic when no label matches', () => { + const p = pull({ additions: 3, deletions: 1, labels: [wl('some-other-label')] }); + expect(derive(p, undefined, NOW, STARVE_DAYS, MAP).weight).toBe('XS'); + }); + + it('an empty config ignores the label (heuristic only)', () => { + const p = pull({ additions: 2000, deletions: 0, labels: [wl('size: XS')] }); + expect(derive(p, undefined, NOW).weight).toBe(reviewWeight(p)); + }); + + it('a label counts as a known size even with adds/dels off the wire', () => { + const p = pull({ additions: null, deletions: null, labels: [wl('size: M')] }); + const d = derive(p, undefined, NOW, STARVE_DAYS, MAP); + expect(d.weight).toBe('M'); + expect(d.sizeKnown).toBe(true); + }); + + it('weightFromLabels only matches configured titles', () => { + expect(weightFromLabels([wl('nope')], MAP)).toBeNull(); + expect(weightFromLabels([wl('size: XL')], MAP)).toBe('XL'); + expect(weightFromLabels([wl('size: L')], new Map())).toBeNull(); + }); +}); diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index 477e7069..7903b814 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -1,4 +1,4 @@ -import type { CommitStatus, PullData, RepoSpec, Signature } from '../types'; +import type { CommitStatus, Label, PullData, RepoSpec, Signature } from '../types'; /** * One pull, one status. Mutually exclusive by precedence — the fix for v1's @@ -190,7 +190,11 @@ export function derive( now: number = Date.now() / 1000, /** the age at which a CR-incomplete pull counts as starved (the user's * "age turns amber" setting; defaults to the model's own threshold). */ - warnDays: number = STARVE_DAYS + warnDays: number = STARVE_DAYS, + /** label title → weight bucket, from config.json's `weightLabels`. A + * matching label is the authoritative review-effort signal and overrides + * the diff-size heuristic; empty map keeps the heuristic. */ + weightLabels: ReadonlyMap = new Map() ): DerivedPull { const st = pull.status; const crBy = activeUsers(st.allCR); @@ -229,7 +233,11 @@ export function derive( const updated = Date.parse(pull.updated_at) / 1000; const ageDays = Math.max(0, Math.floor((now - created) / 86400)); const idleDays = Math.max(0, Math.floor((now - updated) / 86400)); - const sizeKnown = pull.additions != null || pull.deletions != null; + // an org weight label is authoritative (deterministic, per-file-weighted, + // versioned with the labeller) and overrides the diff-size guess. It also + // counts as a known size for the sort, even when adds/dels are off the wire. + const labeledWeight = weightFromLabels(pull.labels, weightLabels); + const sizeKnown = labeledWeight != null || pull.additions != null || pull.deletions != null; const size = (pull.additions ?? 0) + (pull.deletions ?? 0); // Rot is rot whether the pull has zero stamps, one of two, or a stale one // waiting on a re-stamp — the old `crHave === 0` cliff hid half-reviewed @@ -263,7 +271,7 @@ export function derive( signedOffAt, starved, starveScore: starved ? ageDays * Math.max(size, 1) : 0, - weight: reviewWeight(pull), + weight: labeledWeight ?? reviewWeight(pull), conflict, mergeUnknown: pull.mergeable == null, dependent, @@ -276,9 +284,29 @@ export function derive( }; } +/** + * The org's stamped weight label mapped to its bucket, or null if the PR + * carries none we recognize. The mapping is config (config.json's + * `weightLabels`), so the label strings live in one place and a maintainer's + * manual override of the auto label is honored the same as the auto one — the + * board never re-derives weight for a PR that has an authoritative label. + */ +export function weightFromLabels( + labels: Label[], + weightLabels: ReadonlyMap +): Weight | null { + if (weightLabels.size === 0) return null; + for (const l of labels) { + const w = weightLabels.get(l.title); + if (w) return w; + } + return null; +} + /** * Expected review effort from size and sprawl. A cheap prior, not a verdict: - * the UI sorts by it and shows it as a chip; humans override by reading. + * the UI sorts by it and shows it as a chip; humans override by reading. Used + * only when no authoritative weight label is present (see weightFromLabels). */ export function reviewWeight(pull: PullData): Weight { const size = (pull.additions ?? 0) + (pull.deletions ?? 0); diff --git a/frontend-v2/src/prefs.ts b/frontend-v2/src/prefs.ts index fc4934d8..42351217 100644 --- a/frontend-v2/src/prefs.ts +++ b/frontend-v2/src/prefs.ts @@ -1,6 +1,9 @@ +import type { Weight } from './model/status'; import { createPersistentStore } from './storage'; import type { Team } from './types'; +const WEIGHTS: ReadonlySet = new Set(['XS', 'S', 'M', 'L', 'XL']); + /** * Scope is the one saved customization: which repos and people are "my * board". Empty arrays mean everything — a full selection is stored as @@ -35,6 +38,10 @@ export interface SiteConfig { teams: Team[]; /** bot logins beyond the `[bot]` suffix GitHub Apps carry */ bots: string[]; + /** label title → weight bucket. A PR carrying one of these labels takes + * that weight instead of the diff-size guess, so an auto weight label (and + * any manual override of it) drives the board. Empty = heuristic only. */ + weightLabels: Record; } /** @@ -43,11 +50,15 @@ export interface SiteConfig { * bot detection; everything else still works. */ export async function loadSiteConfig(): Promise { - const none: SiteConfig = { teams: [], bots: [] }; + const none: SiteConfig = { teams: [], bots: [], weightLabels: {} }; try { const res = await fetch(`${import.meta.env.BASE_URL}config.json`); if (!res.ok) return none; - const raw = (await res.json()) as { teams?: unknown; bots?: unknown }; + const raw = (await res.json()) as { + teams?: unknown; + bots?: unknown; + weightLabels?: unknown; + }; const teams = Array.isArray(raw.teams) ? raw.teams : []; const bots = Array.isArray(raw.bots) ? raw.bots : []; return { @@ -60,8 +71,20 @@ export async function loadSiteConfig(): Promise { (t as Team).members.every(m => typeof m === 'string') ), bots: bots.filter((b): b is string => typeof b === 'string'), + weightLabels: parseWeightLabels(raw.weightLabels), }; } catch { return none; } } + +/** Keep only entries that map a label title to a real weight bucket, so a typo + * in config can't inject a bogus weight (the model would sort it as unknown). */ +function parseWeightLabels(raw: unknown): Record { + if (!raw || typeof raw !== 'object') return {}; + const out: Record = {}; + for (const [title, w] of Object.entries(raw as Record)) { + if (typeof w === 'string' && WEIGHTS.has(w)) out[title] = w as Weight; + } + return out; +} diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts index 6f1de8da..ece8981a 100644 --- a/frontend-v2/src/store.ts +++ b/frontend-v2/src/store.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from 'react'; import { backend, type ConnectionState } from './backend/socket'; -import { derive, type DerivedPull } from './model/status'; +import { derive, type DerivedPull, type Weight } from './model/status'; import { getSettings, subscribeSettings } from './settings'; import { readStorage, writeStorage } from './storage'; import type { PullData, RepoSpec } from './types'; @@ -34,6 +34,9 @@ const LAST_SEEN_KEY = 'pd2.lastSeen'; const raw = new Map(); let repoSpecs: RepoSpec[] = []; +// label title → weight bucket, from config.json. Set once when config loads; +// a fresh Map reference invalidates the derive cache so weights re-resolve. +let weightLabels: ReadonlyMap = new Map(); let me = ''; let connection: ConnectionState = 'connecting'; let initialized = false; @@ -114,16 +117,32 @@ let snapshot: Snapshot = { // threshold in Settings actually re-derives (starved gates the aging lane). const derived = new WeakMap< PullData, - { spec: RepoSpec | undefined; warnDays: number; value: DerivedPull } + { + spec: RepoSpec | undefined; + warnDays: number; + weightLabels: ReadonlyMap; + value: DerivedPull; + } >(); function deriveCached(pull: PullData, spec: RepoSpec | undefined, warnDays: number): DerivedPull { const hit = derived.get(pull); - if (hit && hit.spec === spec && hit.warnDays === warnDays) return hit.value; - const value = derive(pull, spec, Date.now() / 1000, warnDays); - derived.set(pull, { spec, warnDays, value }); + if (hit && hit.spec === spec && hit.warnDays === warnDays && hit.weightLabels === weightLabels) + return hit.value; + const value = derive(pull, spec, Date.now() / 1000, warnDays, weightLabels); + derived.set(pull, { spec, warnDays, weightLabels, value }); return value; } +/** + * Install the weight-label config (from config.json) and re-derive. Rebuilding + * the Map gives a new reference, which misses the derive cache so every pull's + * weight re-resolves against the labels. Call once after the config loads. + */ +export function setWeightLabels(map: Record) { + weightLabels = new Map(Object.entries(map)); + schedulePublish(); +} + function publish() { const specByName = new Map(repoSpecs.map(s => [s.name, s])); const warnDays = getSettings().ageWarnDays; From fe6d6d3d513e83429ab262ee4f4ae8b65b956349 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 19:46:58 -0700 Subject: [PATCH 090/381] Remove dead code: idleDays, .dot-updated, setOpen, cr_req twins Four verified-dead pieces: - DerivedPull.idleDays was computed on every derive and read nowhere; every sort, cap, and starvation decision uses ageDays. If a stalled-vs-old axis lands later it's a two-line re-add. - .dot-updated had no caller, static or dynamic: closed rows only render the boolean .dot-fresh, and open rows moved to FreshTag long ago. The comment above .dot-fresh described both dots, so it's rewritten. - usePopover returned setOpen but its only caller (Popover) never uses it; toggle absorbed open/close. - PullData typed cr_req/qa_req twice (top level and under status). All consumers read status.cr_req/qa_req; typing only that copy prevents reading the wrong twin. The wire still sends both, noted in a comment. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/usePopover.ts | 2 +- frontend-v2/src/model/status.test.ts | 2 -- frontend-v2/src/model/status.ts | 5 ----- frontend-v2/src/styles.css | 11 +---------- frontend-v2/src/types.ts | 5 +++-- 5 files changed, 5 insertions(+), 20 deletions(-) diff --git a/frontend-v2/src/components/usePopover.ts b/frontend-v2/src/components/usePopover.ts index f16b088f..09d605f1 100644 --- a/frontend-v2/src/components/usePopover.ts +++ b/frontend-v2/src/components/usePopover.ts @@ -82,5 +82,5 @@ export function usePopover clearClose, []); const hoverProps = opts?.hover ? { onMouseEnter, onMouseLeave } : {}; - return { open, setOpen, toggle, rootRef, panelRef, triggerRef, hoverProps }; + return { open, toggle, rootRef, panelRef, triggerRef, hoverProps }; } diff --git a/frontend-v2/src/model/status.test.ts b/frontend-v2/src/model/status.test.ts index 6e85aea0..6e9caed3 100644 --- a/frontend-v2/src/model/status.test.ts +++ b/frontend-v2/src/model/status.test.ts @@ -52,8 +52,6 @@ function pull(overrides: Partial = {}): PullData { head: { ref: 'branch', sha: 'abc', repo: { owner: { login: 'iFixit' } } }, base: { ref: 'master' }, user: { login: 'author' }, - cr_req: 1, - qa_req: 1, status: { cr_req: 1, qa_req: 1, diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index 7903b814..28fffee4 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -70,8 +70,6 @@ export interface DerivedPull { /** epoch secs of the invalidating push (head CI start proxy); null if unknown */ headPushedAt: number | null; ageDays: number; - /** days since updated_at — the activity clock, vs ageDays' open clock */ - idleDays: number; /** epoch secs the last required sign-off landed; null until fully signed off */ signedOffAt: number | null; /** CR-incomplete past STARVE_DAYS — including half-reviewed and stale-CR rot */ @@ -230,9 +228,7 @@ export function derive( else status = 'ready'; const created = Date.parse(pull.created_at) / 1000; - const updated = Date.parse(pull.updated_at) / 1000; const ageDays = Math.max(0, Math.floor((now - created) / 86400)); - const idleDays = Math.max(0, Math.floor((now - updated) / 86400)); // an org weight label is authoritative (deterministic, per-file-weighted, // versioned with the labeller) and overrides the diff-size guess. It also // counts as a known size for the sort, even when adds/dels are off the wire. @@ -267,7 +263,6 @@ export function derive( reqaBy: staleQa, headPushedAt: headPushedAt(pull), ageDays, - idleDays, signedOffAt, starved, starveScore: starved ? ageDays * Math.max(size, 1) : 0, diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 1d14c507..47312e7a 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -273,8 +273,7 @@ a:hover { animation: fresh-flash 1.4s var(--ease-quart) 300ms backwards; } -/* the since-your-last-look markers at the row's entry point: - solid = a brand-new PR, ring = an existing PR that changed */ +/* the since-your-last-look marker on a closed row (open rows use FreshTag) */ .dot-fresh { flex: none; width: 7px; @@ -282,14 +281,6 @@ a:hover { border-radius: 9999px; background: var(--brand); } -.dot-updated { - flex: none; - width: 7px; - height: 7px; - border-radius: 9999px; - border: 2px solid var(--brand); - background: transparent; -} /* compact density: the CardShell already collapses to one line with a smaller avatar and tighter padding; these finish the job for the pieces it doesn't diff --git a/frontend-v2/src/types.ts b/frontend-v2/src/types.ts index acc6f94c..ff7ef6e0 100644 --- a/frontend-v2/src/types.ts +++ b/frontend-v2/src/types.ts @@ -75,8 +75,9 @@ export interface PullData { head: { ref: string; sha: string; repo: { owner: { login: string } } }; base: { ref: string }; user: { login: string }; - cr_req: number; - qa_req: number; + // the wire also sends top-level cr_req/qa_req twins, but status.cr_req/ + // qa_req are the ones every consumer reads — typing one copy prevents + // reading the wrong one status: { cr_req: number; qa_req: number; From b8361defe0988c02f6999afe86c3ca2888865c5e Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 19:48:32 -0700 Subject: [PATCH 091/381] Reuse the cached push time in isIterating The push-time fallback (headPushedAt ?? updated_at) was spelled three times: inside isIterating (rescanning commit_statuses on every call), by hand in rowFlags, and implicitly wherever isIterating was handed pull.data. Worst case was the crSort comparator, which rescanned every pull's commit statuses twice per comparison for a value derive() had already computed and cached as DerivedPull.headPushedAt. isIterating now takes the derived pull and reads that cache; the fallback lives once, in the new lastPushEpoch, which rowFlags shares for its "Pushed N min ago" detail. The iterating tests route through derive() so they exercise the real cached path. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Row.tsx | 6 +++--- frontend-v2/src/model/sort.ts | 2 +- frontend-v2/src/model/status.test.ts | 6 +++--- frontend-v2/src/model/status.ts | 18 +++++++++++++----- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index e2a9c4b3..d9da6ac5 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -1,6 +1,6 @@ import { memo, useState } from 'react'; import type { DerivedPull } from '../model/status'; -import { isIterating } from '../model/status'; +import { isIterating, lastPushEpoch } from '../model/status'; import { rowNote } from '../model/actions'; import { ago, pullKey } from '../format'; import { ackPull, isFresh, refreshPull } from '../store'; @@ -107,7 +107,7 @@ function rowFlags(pull: DerivedPull, showIterating: boolean, aging: boolean): Fl if (p.ci === 'pending' && p.status !== 'ci_pending') flags.push({ key: 'ci', tone: 'note', label: 'CI…', detail: 'CI is still running.' }); if (showIterating) { - const pushedAt = p.headPushedAt ?? Date.parse(p.data.updated_at) / 1000; + const pushedAt = lastPushEpoch(p); flags.push({ key: 'iterating', tone: 'note', @@ -266,7 +266,7 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { // the one action/context line, the same in every lens (model/actions.ts) const note = rowNote(pull, opts.me); // "iterating" and a "fix pushed …" note say the same thing — don't say it twice - const showIterating = isIterating(d) && !note?.text.includes('pushed'); + const showIterating = isIterating(pull) && !note?.text.includes('pushed'); return ( - Number(isIterating(a.data)) - Number(isIterating(b.data)) || + Number(isIterating(a)) - Number(isIterating(b)) || crScore(a) - crScore(b) || b.ageDays - a.ageDays || (a.data.additions ?? 0) + diff --git a/frontend-v2/src/model/status.test.ts b/frontend-v2/src/model/status.test.ts index 6e9caed3..8926417d 100644 --- a/frontend-v2/src/model/status.test.ts +++ b/frontend-v2/src/model/status.test.ts @@ -332,7 +332,7 @@ describe('starvation and weight', () => { const pushed = withStatus({ commit_statuses: [{ data: { ...ci('pending').data, started_at: NOW - 300 } }], }); - expect(isIterating(pushed, NOW)).toBe(true); + expect(isIterating(derive(pushed, undefined, NOW), NOW)).toBe(true); // pushed 2 days ago but commented on just now: NOT iterating const commented = withStatus( { @@ -340,10 +340,10 @@ describe('starvation and weight', () => { }, { updated_at: new Date((NOW - 60) * 1000).toISOString() } ); - expect(isIterating(commented, NOW)).toBe(false); + expect(isIterating(derive(commented, undefined, NOW), NOW)).toBe(false); // no CI reported: falls back to updated_at const bare = pull({ updated_at: new Date((NOW - 60) * 1000).toISOString() }); - expect(isIterating(bare, NOW)).toBe(true); + expect(isIterating(derive(bare, undefined, NOW), NOW)).toBe(true); }); it('names the failing CI checks', () => { diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index 28fffee4..821c352f 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -313,15 +313,23 @@ export function reviewWeight(pull: PullData): Weight { export const weightRank = (w: Weight) => WEIGHT_RANK[w]; +/** Epoch secs of the last push, from the derived pull's cached headPushedAt, + * falling back to updated_at when no CI has reported a push time. */ +export const lastPushEpoch = (p: Pick): number => + p.headPushedAt ?? Date.parse(p.data.updated_at) / 1000; + /** * The author pushed in the last 30 minutes: probably still iterating. The * UI demotes (dims and sinks), never hides. Keyed to the PUSH clock, not * updated_at — 62% of this shop's PRs merge same-day, and updated_at moves * on every comment, so the old check sank pulls exactly while reviewers - * were engaging with them. Falls back to updated_at when no CI has - * reported a push time. + * were engaging with them. Takes the derived pull so callers (including the + * crSort comparator) reuse the cached push time instead of rescanning + * commit statuses. */ -export function isIterating(pull: PullData, now: number = Date.now() / 1000) { - const pushed = headPushedAt(pull) ?? Date.parse(pull.updated_at) / 1000; - return now - pushed < 30 * 60; +export function isIterating( + p: Pick, + now: number = Date.now() / 1000 +) { + return now - lastPushEpoch(p) < 30 * 60; } From d05f80127a59172a222f8dba05ff186bc61be7c6 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 19:50:06 -0700 Subject: [PATCH 092/381] Share the done-predicates and lane-cap math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two conventions were each spelled in multiple places: - "is CR/QA signed off" exists as crDone/qaDone precisely so every view agrees, but derive() recomputed it inline as crMet/qaMet and Review.tsx spelled the raw comparison twice more. All now call the helpers, so a future change to the done definition lands in one place. - The lane cap convention (user setting over lane default, 0 = no cap, compact packs 50% more) lived in Lane and was half-reimplemented by the "Yours to do" lane, which dropped the compact bonus — the top lane on the page was the only one not showing more rows in compact density. Extract laneShown() and use it from both. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Lane.tsx | 17 ++++++++++++----- frontend-v2/src/model/status.ts | 4 ++-- frontend-v2/src/views/Review.tsx | 13 +++++-------- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index 197e115f..b90e1e93 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -37,6 +37,17 @@ function GroupHeader({ ); } +/** + * Rows a lane shows before folding into "+N more": the user's lane-length + * setting (0 = no cap) over the lane's default, with compact density packing + * ~50% more rows per fold. The one cap convention — any lane that can't use + * (custom children) must still cap through this. + */ +export function laneShown(defaultCap: number, opts: RowOptions): number { + const base = opts.laneCap ?? defaultCap; + return base === 0 ? Number.POSITIVE_INFINITY : opts.compact ? Math.ceil(base * 1.5) : base; +} + export function Lane({ title, sub, @@ -57,11 +68,7 @@ export function Lane({ children?: ReactNode; }) { if (!pulls.length && !children) return null; - // the user's lane-length setting (0 = no cap) overrides the lane's default; - // compact packs ~50% more rows per screen, so show more before folding - const base = opts.laneCap ?? cap; - const shown = - base === 0 ? Number.POSITIVE_INFINITY : opts.compact ? Math.ceil(base * 1.5) : base; + const shown = laneShown(cap, opts); return (
= st.cr_req; - const qaMet = qaHave >= st.qa_req; + const crMet = crDone({ crHave, data: pull }); + const qaMet = qaDone({ qaHave, data: pull }); const conflict = pull.mergeable === false; const dependent = !['main', 'master'].includes(pull.base.ref); diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 12d8b3aa..27533168 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -1,4 +1,4 @@ -import type { DerivedPull, Status } from '../model/status'; +import { type DerivedPull, qaDone, type Status } from '../model/status'; import { pullKey } from '../format'; import { crSort } from '../model/sort'; import { authorMove, reviewerMove } from '../model/actions'; @@ -6,7 +6,7 @@ import { useSettings } from '../settings'; import { isFresh } from '../store'; import type { PullData } from '../types'; import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; -import { Fold, FoldRows, Lane, RestGroup, Truncated } from '../components/Lane'; +import { Fold, FoldRows, Lane, laneShown, RestGroup, Truncated } from '../components/Lane'; import { Row, type RowOptions } from '../components/Row'; import { ClosedRow } from '../components/ClosedRow'; @@ -102,7 +102,7 @@ export function Review({ const needsQa = others .filter( p => - p.qaHave < p.data.status.qa_req && + !qaDone(p) && ['success', 'none'].includes(p.ci) && !p.conflict && !['draft', 'dev_block'].includes(p.status) && @@ -127,7 +127,7 @@ export function Review({ ); // QA's symmetric case: you gave a QA stamp but qa_req wants more. Without // this the PR sits in "Needs QA" as if you never touched it. - const qaStamped = others.filter(p => p.qaHave < p.data.status.qa_req && p.qaBy.includes(me)); + const qaStamped = others.filter(p => !qaDone(p) && p.qaBy.includes(me)); const byStatus = (s: Status) => others.filter(p => p.status === s); const devBlocked = byStatus('dev_block'); const deployHeld = byStatus('deploy_block'); @@ -159,10 +159,7 @@ export function Review({ <> {todo.length > 0 && ( - + {todo.map(({ p }) => ( ))} From 8505aee80dd21b1ab3f3385eef5e2e93dd4d4d07 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 19:51:04 -0700 Subject: [PATCH 093/381] Rename for readability: qaingLogin, scoped, isDevBlocked Three naming fixes from the cleanup audit, no behavior change: - qaingBy broke the *By convention: every sibling (crBy, qaBy, recrBy, devBlockedBy, ...) is a string[] of logins, but qaingBy was a single login-or-null, so the natural array reading was wrong at every call site. Now qaingLogin, which says both the meaning and the shape. - app.tsx computed inScope and immediately aliased it (const scoped = inScope), forcing readers to hold two names for one value. The memo is now just scoped. - Classic.tsx's devBlock/deployBlock were boolean predicates named as nouns; isDevBlocked/isDeployBlocked read as the questions they answer. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 5 +---- frontend-v2/src/model/actions.test.ts | 6 +++--- frontend-v2/src/model/actions.ts | 6 +++--- frontend-v2/src/model/status.test.ts | 2 +- frontend-v2/src/model/status.ts | 4 ++-- frontend-v2/src/views/Classic.tsx | 22 +++++++++++----------- frontend-v2/src/views/Review.tsx | 6 +++--- 7 files changed, 24 insertions(+), 27 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index f1acbf51..2e1b357f 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -330,7 +330,7 @@ export function App() { [reveal, scope.repos, legacy, queryRepos] ); - const inScope = useMemo(() => { + const scoped = useMemo(() => { let out = pulls; if (legacy) out = applyLegacyFilters(out, legacy, me); // Muted (user) and org-hidden repos, and cryo PRs, stay off the board @@ -371,9 +371,6 @@ export function App() { draftsMode, ]); - // "what changed since your last look" is now a lane in Review, not a bar - // toggle, so the pool is just the scoped board - const scoped = inScope; const humans = scoped.filter(p => !isBot(p)); const bots = scoped.filter(isBot); // "did my PR merge over the weekend" is the cheapest answer the board can diff --git a/frontend-v2/src/model/actions.test.ts b/frontend-v2/src/model/actions.test.ts index 09fd4d99..a53bbced 100644 --- a/frontend-v2/src/model/actions.test.ts +++ b/frontend-v2/src/model/actions.test.ts @@ -7,7 +7,7 @@ function dp(o: { author?: string; status: Status; conflict?: boolean; - qaingBy?: string | null; + qaingLogin?: string | null; reqaBy?: string[]; recrBy?: string[]; }): DerivedPull { @@ -15,7 +15,7 @@ function dp(o: { data: { user: { login: o.author ?? 'author' } }, status: o.status, conflict: o.conflict ?? false, - qaingBy: o.qaingBy ?? null, + qaingLogin: o.qaingLogin ?? null, reqaBy: o.reqaBy ?? [], recrBy: o.recrBy ?? [], } as unknown as DerivedPull; @@ -43,7 +43,7 @@ describe('alertMove — which transitions earn a desktop nudge', () => { 'Re-QA' ); // claiming QA yourself is not an incoming event - expect(alertMove(dp({ author: 'a', status: 'needs_qa', qaingBy: 'me' }), 'me')).toBeNull(); + expect(alertMove(dp({ author: 'a', status: 'needs_qa', qaingLogin: 'me' }), 'me')).toBeNull(); // a fresh needs-CR isn't owed by anyone in particular expect(alertMove(dp({ author: 'a', status: 'needs_cr' }), 'me')).toBeNull(); }); diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index badb0521..4b8463a9 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -15,7 +15,7 @@ export function authorMove(p: DerivedPull): string | null { // it", which misroutes the most common author action if (p.status === 'dev_block') return 'Address feedback'; if (p.status === 'unmergeable' || p.conflict) return 'Rebase'; - if (p.status === 'needs_qa' && !p.qaingBy && !p.reqaBy.length) return 'Find a QA-er'; + if (p.status === 'needs_qa' && !p.qaingLogin && !p.reqaBy.length) return 'Find a QA-er'; if (p.status === 'draft') return 'Finish the draft'; return null; } @@ -24,7 +24,7 @@ export function authorMove(p: DerivedPull): string | null { export function reviewerMove(p: DerivedPull, me: string): string | null { if (p.data.user.login === me) return null; if (p.status === 'needs_recr' && p.recrBy.includes(me)) return 'Re-stamp'; - if (p.status === 'needs_qa' && p.qaingBy === me) return 'Finish QA'; + if (p.status === 'needs_qa' && p.qaingLogin === me) return 'Finish QA'; if (p.status === 'needs_qa' && p.reqaBy.includes(me)) return 'Re-QA'; return null; } @@ -82,7 +82,7 @@ export function rowNote(p: DerivedPull, me: string): RowNote | null { case 'needs_recr': return p.recrBy.length ? wait(`waiting on ${who(p.recrBy)}${pushed}`) : null; case 'needs_qa': - if (p.qaingBy) return wait(`${who([p.qaingBy])} is testing it`); + if (p.qaingLogin) return wait(`${who([p.qaingLogin])} is testing it`); if (p.reqaBy.length) return wait(`${who(p.reqaBy)}’s QA fell to a push`); return null; case 'needs_cr': diff --git a/frontend-v2/src/model/status.test.ts b/frontend-v2/src/model/status.test.ts index 8926417d..18589e79 100644 --- a/frontend-v2/src/model/status.test.ts +++ b/frontend-v2/src/model/status.test.ts @@ -204,7 +204,7 @@ describe('status derivation precedence', () => { ], }); const d = derive(labeled, undefined, NOW); - expect(d.qaingBy).toBe('tester'); + expect(d.qaingLogin).toBe('tester'); expect(d.cryo).toBe(true); expect(d.externalBlock).toBe(false); }); diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index fea002a5..267d5ff9 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -89,7 +89,7 @@ export interface DerivedPull { /** everyone holding an active deploy block: done, deliberately not shipped */ deployBlockedBy: string[]; /** login from the QAing label: someone is already testing this */ - qaingBy: string | null; + qaingLogin: string | null; externalBlock: boolean; cryo: boolean; } @@ -273,7 +273,7 @@ export function derive( sizeKnown, devBlockedBy, deployBlockedBy, - qaingBy: label(LABELS.qaing)?.user ?? null, + qaingLogin: label(LABELS.qaing)?.user ?? null, externalBlock: !!label(LABELS.externalBlock), cryo: !!label(LABELS.cryo), }; diff --git a/frontend-v2/src/views/Classic.tsx b/frontend-v2/src/views/Classic.tsx index 16db783c..0b1d65b0 100644 --- a/frontend-v2/src/views/Classic.tsx +++ b/frontend-v2/src/views/Classic.tsx @@ -18,8 +18,8 @@ import { Row, type RowOptions } from '../components/Row'; // v1 predicate ports. Where v1 read raw wire fields (dev_block[0] with no // active check), so does this — near-unchanged beats more-correct here. -const devBlock = (p: DerivedPull) => !!p.data.status.dev_block[0]; -const deployBlock = (p: DerivedPull) => !!p.data.status.deploy_block[0]; +const isDevBlocked = (p: DerivedPull) => !!p.data.status.dev_block[0]; +const isDeployBlocked = (p: DerivedPull) => !!p.data.status.deploy_block[0]; const isDraft = (p: DerivedPull) => p.data.draft; // v1 hasPassedCI(): every required status successful; no statuses and no // required list counts as passed. ciVerdict encodes exactly that split. @@ -42,10 +42,10 @@ const defaultCompare = (me: string) => (a: DerivedPull, b: DerivedPull) => b.data.created_at.localeCompare(a.data.created_at); const qaCompare = (me: string) => (a: DerivedPull, b: DerivedPull) => - cmp(a.qaingBy === me, b.qaingBy === me) || + cmp(a.qaingLogin === me, b.qaingLogin === me) || cmp(!a.externalBlock, !b.externalBlock) || cmp(!a.conflict, !b.conflict) || - cmp(!a.qaingBy, !b.qaingBy) || + cmp(!a.qaingLogin, !b.qaingLogin) || cmp(crDone(a), crDone(b)) || b.data.created_at.localeCompare(a.data.created_at); @@ -127,25 +127,25 @@ export function Classic({ } const base = [...pulls].sort(defaultCompare(me)); - const ciBlocked = base.filter(p => !devBlock(p) && !passedCI(p) && !isDraft(p)); + const ciBlocked = base.filter(p => !isDevBlocked(p) && !passedCI(p) && !isDraft(p)); const deployBlocked = base .filter( - p => metDeployReqs(p) && !devBlock(p) && (deployBlock(p) || p.conflict || p.dependent) + p => metDeployReqs(p) && !isDevBlocked(p) && (isDeployBlocked(p) || p.conflict || p.dependent) ) .sort(deployCompare); const ready = base.filter( p => metDeployReqs(p) && !isDraft(p) && - !devBlock(p) && - !deployBlock(p) && + !isDevBlocked(p) && + !isDeployBlocked(p) && !p.conflict && !p.dependent ); - const devBlocked = base.filter(p => devBlock(p) || isDraft(p)); - const needsCr = base.filter(p => !crDone(p) && !devBlock(p) && !isDraft(p)); + const devBlocked = base.filter(p => isDevBlocked(p) || isDraft(p)); + const needsCr = base.filter(p => !crDone(p) && !isDevBlocked(p) && !isDraft(p)); const needsQa = base - .filter(p => !qaDone(p) && !devBlock(p) && !isDraft(p) && !p.conflict && passedCI(p)) + .filter(p => !qaDone(p) && !isDevBlocked(p) && !isDraft(p) && !p.conflict && passedCI(p)) .sort(qaCompare(me)); // ids match v1's column collapse params (?ci=0&cr=0…) so old URLs map 1:1 diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 27533168..e650faef 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -108,14 +108,14 @@ export function Review({ !['draft', 'dev_block'].includes(p.status) && // your in-flight QA and owed re-QAs live in "Yours to do"; a QA // stamp you already gave lives in the "QA'd by you" fold - p.qaingBy !== me && + p.qaingLogin !== me && !p.qaBy.includes(me) && !(p.status === 'needs_qa' && p.reqaBy.includes(me)) ) .sort( (a, b) => - Number(b.qaingBy === me) - Number(a.qaingBy === me) || - Number(!!a.qaingBy && a.qaingBy !== me) - Number(!!b.qaingBy && b.qaingBy !== me) || + Number(b.qaingLogin === me) - Number(a.qaingLogin === me) || + Number(!!a.qaingLogin && a.qaingLogin !== me) - Number(!!b.qaingLogin && b.qaingLogin !== me) || b.ageDays - a.ageDays ); From 42beef09631d4a14fe87c40defc26b6620510299 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 19:52:50 -0700 Subject: [PATCH 094/381] Share the epoch conversion and merge the twin stagger rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Date.parse(x) / 1000 was spelled raw at ten call sites with no name for "ISO string → epoch seconds". Add epoch() to format.ts and swap the production sites in (closedEpoch now builds on it too). Sites where the conversion isn't epoch-seconds semantics (millisecond comparisons in sorts) are deliberately left alone. The .settle-once and .reveal-stagger stagger rules were the same animation and near-identical delay schedules, maintained twice. Merge them into shared selectors with one schedule; the fold reveal gains the 160ms fifth step first paint already had, an invisible difference. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 4 ++-- frontend-v2/src/components/Row.tsx | 8 ++++---- frontend-v2/src/components/bits.tsx | 4 ++-- frontend-v2/src/format.ts | 7 ++++++- frontend-v2/src/model/status.ts | 7 ++++--- frontend-v2/src/store.ts | 3 ++- frontend-v2/src/styles.css | 24 +++++++++++------------- 7 files changed, 31 insertions(+), 26 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 2e1b357f..88cd8344 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { ago, n, shortRepo } from './format'; +import { ago, epoch, n, shortRepo } from './format'; import type { DerivedPull } from './model/status'; import type { Team } from './types'; import { setWeightLabels, usePulldasher } from './store'; @@ -376,7 +376,7 @@ export function App() { // "did my PR merge over the weekend" is the cheapest answer the board can // give — a slim banner, since the open-PR changes live in the lane const mergedCount = closed.filter( - p => (Date.parse(p.closed_at ?? '') / 1000 || 0) > lastSeen + p => (epoch(p.closed_at ?? '') || 0) > lastSeen ).length; // every known repo with its open-PR count — feeds the Filters popover and // the Settings repo manager. Includes org-hidden and pref'd repos at 0. diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index d9da6ac5..d4d43130 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -2,7 +2,7 @@ import { memo, useState } from 'react'; import type { DerivedPull } from '../model/status'; import { isIterating, lastPushEpoch } from '../model/status'; import { rowNote } from '../model/actions'; -import { ago, pullKey } from '../format'; +import { ago, epoch, pullKey } from '../format'; import { ackPull, isFresh, refreshPull } from '../store'; import { AgeStamp, DiffSize, FreshTag, RepoRef, SigPips, StatusBadge, WeightMeter } from './bits'; import { CardShell } from './Card'; @@ -32,7 +32,7 @@ export interface RowOptions { */ function freshKind(p: DerivedPull, opts: RowOptions): 'new' | 'updated' | null { if (!isFresh(p.data, opts.lastSeen, opts.acked)) return null; - return Date.parse(p.data.created_at) / 1000 > opts.lastSeen ? 'new' : 'updated'; + return epoch(p.data.created_at) > opts.lastSeen ? 'new' : 'updated'; } // The background flash runs once per pull per session, not on every lens @@ -249,8 +249,8 @@ function MetricRail({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { /> = { @@ -337,7 +337,7 @@ export function SigPips({ {s.data.user.login} {s.data.user.login === me && (you)} - {ago(Date.parse(s.data.created_at) / 1000)} ago + {ago(epoch(s.data.created_at))} ago {s.data.active ? ( diff --git a/frontend-v2/src/format.ts b/frontend-v2/src/format.ts index c1b7c4b7..8416a810 100644 --- a/frontend-v2/src/format.ts +++ b/frontend-v2/src/format.ts @@ -41,13 +41,18 @@ export function pullKey(d: { repo: string; number: number }) { return `${d.repo}#${d.number}`; } +/** ISO date string → epoch seconds, the clock unit everything else here uses. */ +export function epoch(iso: string): number { + return Date.parse(iso) / 1000; +} + /** * When a pull actually stopped moving, in epoch seconds: its close time, or * last activity as a fallback for the rare pull with no `closed_at`. The one * definition of the "closed at" rule the closed-row card and its sort share. */ export function closedEpoch(pull: Pick) { - return Date.parse(pull.closed_at ?? pull.updated_at) / 1000; + return epoch(pull.closed_at ?? pull.updated_at); } /** "1 PR" / "3 PRs" — counts read as grammar, not as a template. */ diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index 267d5ff9..64abcaf4 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -1,3 +1,4 @@ +import { epoch } from '../format'; import type { CommitStatus, Label, PullData, RepoSpec, Signature } from '../types'; /** @@ -227,7 +228,7 @@ export function derive( else if (conflict || dependent) status = 'unmergeable'; else status = 'ready'; - const created = Date.parse(pull.created_at) / 1000; + const created = epoch(pull.created_at); const ageDays = Math.max(0, Math.floor((now - created) / 86400)); // an org weight label is authoritative (deterministic, per-file-weighted, // versioned with the labeller) and overrides the diff-size guess. It also @@ -246,7 +247,7 @@ export function derive( 0, ...[...st.allCR, ...st.allQA] .filter(s => s.data.active) - .map(s => Date.parse(s.data.created_at) / 1000) + .map(s => epoch(s.data.created_at)) ) || null : null; @@ -316,7 +317,7 @@ export const weightRank = (w: Weight) => WEIGHT_RANK[w]; /** Epoch secs of the last push, from the derived pull's cached headPushedAt, * falling back to updated_at when no CI has reported a push time. */ export const lastPushEpoch = (p: Pick): number => - p.headPushedAt ?? Date.parse(p.data.updated_at) / 1000; + p.headPushedAt ?? epoch(p.data.updated_at); /** * The author pushed in the last 30 minutes: probably still iterating. The diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts index ece8981a..3892dc19 100644 --- a/frontend-v2/src/store.ts +++ b/frontend-v2/src/store.ts @@ -2,6 +2,7 @@ import { useSyncExternalStore } from 'react'; import { backend, type ConnectionState } from './backend/socket'; import { derive, type DerivedPull, type Weight } from './model/status'; import { getSettings, subscribeSettings } from './settings'; +import { epoch } from './format'; import { readStorage, writeStorage } from './storage'; import type { PullData, RepoSpec } from './types'; @@ -95,7 +96,7 @@ export function isFresh( lastSeenAt: number, ackedKeys: ReadonlySet ) { - return Date.parse(d.updated_at) / 1000 > lastSeenAt && !ackedKeys.has(`${d.repo}#${d.number}`); + return epoch(d.updated_at) > lastSeenAt && !ackedKeys.has(`${d.repo}#${d.number}`); } let snapshot: Snapshot = { diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 47312e7a..3fe2aa5b 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -215,24 +215,22 @@ a:hover { translate: 0 8px; } } -/* first paint only: tab switches into a task must be instant */ -.settle-once > * { - animation: settle 300ms var(--ease-quint) backwards; -} -.settle-once > *:nth-child(2) { animation-delay: 40ms; } -.settle-once > *:nth-child(3) { animation-delay: 80ms; } -.settle-once > *:nth-child(4) { animation-delay: 120ms; } -.settle-once > *:nth-child(n + 5) { animation-delay: 160ms; } - -/* rows revealed by "+N more": the same settle, so opening a fold reads as - the list growing, not a hard jump. display:contents on the wrapper keeps - the rows in the container's flow (borders, hover) unchanged. */ +/* one staggered entrance, two moments: .settle-once on first paint only (tab + switches into a task must be instant), .reveal-stagger on the rows a + "+N more" click reveals (display:contents on that wrapper keeps the rows in + the container's flow — borders, hover — unchanged) */ +.settle-once > *, .reveal-stagger > * { animation: settle 300ms var(--ease-quint) backwards; } +.settle-once > *:nth-child(2), .reveal-stagger > *:nth-child(2) { animation-delay: 40ms; } +.settle-once > *:nth-child(3), .reveal-stagger > *:nth-child(3) { animation-delay: 80ms; } -.reveal-stagger > *:nth-child(n + 4) { animation-delay: 120ms; } +.settle-once > *:nth-child(4), +.reveal-stagger > *:nth-child(4) { animation-delay: 120ms; } +.settle-once > *:nth-child(n + 5), +.reveal-stagger > *:nth-child(n + 5) { animation-delay: 160ms; } @keyframes pop { from { From 17dc64b9d43824a378413dddb22306e24cd354b5 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 19:55:17 -0700 Subject: [PATCH 095/381] Extract QuietButton from seven copy-pasted classNames The quiet outlined button existed as the same long className pasted four times in the repo manager (one brand-toned) and three times in Settings (the h-8 panel variant). Any radius or hover tweak needed seven synchronized edits. One QuietButton in bits.tsx now carries the shape (sm row action, md panel action) and tone. The two intentional outliers stay inline: the icon-only close button (square, centered) and the destructive clear-settings button (armed red state) are different designs, not copies. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/RepoManager.tsx | 33 ++++++---------------- frontend-v2/src/components/Settings.tsx | 24 ++++++---------- frontend-v2/src/components/bits.tsx | 29 +++++++++++++++++++ 3 files changed, 46 insertions(+), 40 deletions(-) diff --git a/frontend-v2/src/components/RepoManager.tsx b/frontend-v2/src/components/RepoManager.tsx index 171f74fc..729f8597 100644 --- a/frontend-v2/src/components/RepoManager.tsx +++ b/frontend-v2/src/components/RepoManager.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { repoState } from '../model/visibility'; +import { QuietButton } from './bits'; export function RepoManagerGroup({ repos, @@ -62,13 +63,7 @@ export function RepoManagerGroup({
{repo.name} {repo.count} - + onRepoPref(repo.name, null)}>Unmute
))}
@@ -83,13 +78,9 @@ export function RepoManagerGroup({
{repo.name} {repo.count} - +
))}
@@ -132,21 +123,13 @@ export function RepoManagerGroup({ )} {repo.count} {isUserRevealed && ( - + )} - +
); })} diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index f13e64ee..3ee1b0be 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -16,7 +16,7 @@ import { togglePrimaryRepo, useSettings, } from '../settings'; -import { Segmented } from './bits'; +import { QuietButton, Segmented } from './bits'; import { RepoManagerGroup } from './RepoManager'; const LENS_OPTIONS: [string, string][] = [ @@ -335,13 +335,9 @@ export function Settings({ /> {s.notify && perm === 'granted' && ( - + )} ) : ( @@ -366,8 +362,8 @@ export function Settings({ webhook. The board updates as each one comes back.
- + {refreshNote && ( {refreshNote} )} @@ -427,17 +422,16 @@ export function Settings({ />
- + {seenNote && ( done diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index dbed7bc9..ccf55586 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -1,3 +1,4 @@ +import type { ButtonHTMLAttributes } from 'react'; import { ROT_DAYS, STARVE_DAYS, type Status, type Weight, weightRank } from '../model/status'; import type { Signature } from '../types'; import { ago, epoch, githubUrl, loginHue, shortRepo, signatureUrl } from '../format'; @@ -50,6 +51,34 @@ export const STATUS_DOT: Record = { draft: 'var(--border)', }; +/** + * The quiet outlined button the settings surfaces share — one definition so a + * radius or hover tweak lands everywhere. sm = inline row actions (mute, + * unmute), md = standalone panel actions. tone='brand' for the affirmative + * variant ("Show for me"). + */ +export function QuietButton({ + size = 'sm', + tone = 'default', + ...props +}: ButtonHTMLAttributes & { + size?: 'sm' | 'md'; + tone?: 'default' | 'brand'; +}) { + const shape = + size === 'sm' + ? 'rounded-md px-2 py-0.5 text-xs' + : 'inline-flex h-8 items-center rounded-lg px-3 text-[13px]'; + const text = tone === 'brand' ? 'text-brand' : 'text-ink-2'; + return ( + - ); - const search = (value: string, set: (v: string) => void, label: string) => ( )} > -
- {tabBtn('repos', 'Repos')} - {tabBtn('people', 'People')} - {tabBtn('drafts', 'Drafts')} +
+
{tab === 'repos' && ( diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 3ee1b0be..de2e9422 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -119,11 +119,36 @@ export function Settings({ if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); + // aria-modal promises a focus trap; without this, Tab walks out of + // the dialog into the live board behind the scrim + if (e.key === 'Tab' && panelRef.current) { + const focusables = panelRef.current.querySelectorAll( + 'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])' + ); + if (!focusables.length) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement; + if (e.shiftKey && (active === first || active === panelRef.current)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } }; document.addEventListener('keydown', onKey); + // a modal locks the page behind it: the scrim already blocks clicks, + // this blocks scroll + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; // focus the panel so Escape and tabbing land inside it panelRef.current?.focus(); - return () => document.removeEventListener('keydown', onKey); + return () => { + document.removeEventListener('keydown', onKey); + document.body.style.overflow = prevOverflow; + }; }, [open]); // return focus to the cog when the panel closes diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index ccf55586..bd632a9d 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -1,4 +1,4 @@ -import type { ButtonHTMLAttributes } from 'react'; +import type { ButtonHTMLAttributes, KeyboardEvent as ReactKeyboardEvent } from 'react'; import { ROT_DAYS, STARVE_DAYS, type Status, type Weight, weightRank } from '../model/status'; import type { Signature } from '../types'; import { ago, epoch, githubUrl, loginHue, shortRepo, signatureUrl } from '../format'; @@ -490,19 +490,45 @@ export function Segmented({ onChange: (next: T) => void; ariaLabel: string; }) { + // role=radio sets the APG expectation: one Tab stop for the group, arrows + // move the selection. Without this the role announces a contract ("radio + // 1 of 3, use arrows") the control doesn't honor. + const hasSelection = options.some(([v]) => v === value); + const moveSelection = (e: ReactKeyboardEvent, from: number) => { + const delta = + e.key === 'ArrowRight' || e.key === 'ArrowDown' + ? 1 + : e.key === 'ArrowLeft' || e.key === 'ArrowUp' + ? -1 + : 0; + const next = + e.key === 'Home' + ? 0 + : e.key === 'End' + ? options.length - 1 + : delta + ? (from + delta + options.length) % options.length + : null; + if (next == null) return; + e.preventDefault(); + onChange(options[next][0]); + (e.currentTarget.parentElement?.children[next] as HTMLElement | undefined)?.focus(); + }; return (
- {options.map(([val, label]) => ( + {options.map(([val, label], i) => (
@@ -457,11 +460,9 @@ export function Settings({ > Mark everything as seen - {seenNote && ( - - done - - )} + + {seenNote ? 'done' : ''} +
diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index bd632a9d..2afc3a4c 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -346,7 +346,9 @@ export function SigPips({ {...t} type="button" title={`who ${label}’d this`} - className="cursor-pointer rounded border-0 bg-transparent p-0 text-left hover:bg-secondary/60" + // py+negative-my: a real tap target (the pips are 8px squares) + // without moving anything in the rail's layout + className="-my-2 cursor-pointer rounded border-0 bg-transparent px-0 py-2 text-left hover:bg-secondary/60" > {pips} From c020efb6650400441fcdbee4f7a6784caf9f43c9 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 20:11:09 -0700 Subject: [PATCH 098/381] Polish copy and visual consistency across surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The polish audit's copy/drift findings, applied: - The Legend showed "Can't merge" in amber (badge-blocked) while the real status renders gray (badge-hold) — the one component whose job is teaching the colors taught the wrong one. - Curly apostrophes everywhere users read them (Can't/QA'd/PR's/won't had straight quotes among curly siblings). - "PRs sign-off, on the board" on the CR leaderboard wasn't a sentence; now "PRs CR'd" matching its QA sibling. - Classic's column set said "CI Blocked / Deploy Blocked / Dev Block" — the odd one out is now "Dev Blocked". - Button/label casing: the Filters repo actions (unmute/show/mute) match their Sentence-case twins in Settings; the main search placeholder capitalizes "Filter" like every other filter input; Classic's empty column text is "None", not "none". - Same-role elements now share metrics: the status swatch dots (8px), the "+N more" and fold-summary row padding (9px), the muted section headers (text-xs), and both lenses' shipped fold (label + hint split). Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 2 +- frontend-v2/src/components/Filters.tsx | 6 +++--- frontend-v2/src/components/Lane.tsx | 2 +- frontend-v2/src/components/Legend.tsx | 2 +- frontend-v2/src/components/RepoManager.tsx | 6 +++--- frontend-v2/src/components/Settings.tsx | 6 +++--- frontend-v2/src/components/bits.tsx | 2 +- frontend-v2/src/views/Classic.tsx | 6 +++--- frontend-v2/src/views/MyWork.tsx | 7 ++++++- frontend-v2/src/views/Review.tsx | 4 ++-- frontend-v2/src/views/Stats.tsx | 4 ++-- frontend-v2/src/views/stats/StatusBar.tsx | 2 +- 12 files changed, 27 insertions(+), 22 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 12bf96ea..ac4f3176 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -544,7 +544,7 @@ export function App() { ref={searchRef} type="search" aria-label="Filter PRs: text, #number, label:x, status:x, older:5, repo:x, author:x" - placeholder="filter (press /)" + placeholder="Filter (press /)" title="text, #number, label:x, status:x, older:5, repo:x, author:x" value={query} onChange={e => setQuery(e.target.value)} diff --git a/frontend-v2/src/components/Filters.tsx b/frontend-v2/src/components/Filters.tsx index b647aa08..ad96fd4c 100644 --- a/frontend-v2/src/components/Filters.tsx +++ b/frontend-v2/src/components/Filters.tsx @@ -151,7 +151,7 @@ export function Filters({ onClick={() => setRepoPref(name, null)} className="rounded border-0 bg-transparent px-1 text-[11px] text-ink-3 hover:text-brand" > - unmute + Unmute ) : state === 'org-hidden' ? ( ) : ( )}
diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index b90e1e93..c23ab70f 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -151,7 +151,7 @@ export function Truncated({ diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 181443a6..e51c128c 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -96,7 +96,7 @@ export function Legend() { 'done, deliberately kept from shipping: ask who blocked it' )} {item( - Can't merge, + Can’t merge, 'signed off but conflicted or on an unmerged parent: author rebases' )} {item( diff --git a/frontend-v2/src/components/RepoManager.tsx b/frontend-v2/src/components/RepoManager.tsx index d993fbb4..091e870a 100644 --- a/frontend-v2/src/components/RepoManager.tsx +++ b/frontend-v2/src/components/RepoManager.tsx @@ -56,7 +56,7 @@ export function RepoManagerGroup({ {muted.length > 0 && (
-
+
Muted by you ({muted.length})
{muted.map(repo => ( @@ -71,7 +71,7 @@ export function RepoManagerGroup({ {orgHiddenRepos.length > 0 && (
-
+
Hidden by the org ({orgHiddenRepos.length})
{orgHiddenRepos.map(repo => ( @@ -88,7 +88,7 @@ export function RepoManagerGroup({ {shown.length > 0 && (
-
+
On your board ({shown.length})
diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index d1922a15..99bd7405 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -272,7 +272,7 @@ export function Settings({ = { // now says whose move it is dev_block: 'Dev blocked', deploy_block: 'Deploy block', - unmergeable: "Can't merge", + unmergeable: 'Can’t merge', ci_red: 'CI red', draft: 'Draft', }; diff --git a/frontend-v2/src/views/Classic.tsx b/frontend-v2/src/views/Classic.tsx index 0b1d65b0..d7c6add6 100644 --- a/frontend-v2/src/views/Classic.tsx +++ b/frontend-v2/src/views/Classic.tsx @@ -63,7 +63,7 @@ function Column({ defaultOpen?: boolean; }) { return ( - + {/* a 60-row CR column is a 3600px scroll: cap it, keep the count honest */} {pulls.map(p => ( @@ -100,7 +100,7 @@ function ClosedCard({ pull }: { pull: PullData }) { function ClosedColumn({ pulls }: { pulls: PullData[] }) { const ordered = [...pulls].sort((a, b) => closedEpoch(b) - closedEpoch(a)); return ( - + {ordered.map(p => ( ))} @@ -153,7 +153,7 @@ export function Classic({ ['ci', 'CI Blocked', ciBlocked], ['dep', 'Deploy Blocked', deployBlocked], ['ready', 'Ready', ready], - ['dev', 'Dev Block', devBlocked], + ['dev', 'Dev Blocked', devBlocked], ['cr', 'CR', needsCr], ['qa', 'QA', needsQa], ]; diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index 67ca9278..ee12394a 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -63,7 +63,12 @@ export function MyWork({ {shipped.length > 0 && ( - + {shipped.map(p => ( diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index e650faef..078667ef 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -221,7 +221,7 @@ export function Review({ @@ -240,7 +240,7 @@ export function Review({ diff --git a/frontend-v2/src/views/Stats.tsx b/frontend-v2/src/views/Stats.tsx index 5ad5940a..c52ac068 100644 --- a/frontend-v2/src/views/Stats.tsx +++ b/frontend-v2/src/views/Stats.tsx @@ -54,7 +54,7 @@ export function Stats({
( {it.label} From c8f5cecadc9970aba4a13d7c2dadea95e3eb489c Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Fri, 17 Jul 2026 20:24:01 -0700 Subject: [PATCH 099/381] Name the row-flag trigger for assistive tech Browser QA showed the flag-cluster popover trigger exposing no reliable accessible name in the accessibility tree; every other icon/cluster button on the row carries one. Name it from its own flags ("row flags: recent changes, deploy block") so a screen reader hears what the cluster holds before opening it. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Row.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 7d5458fd..d664571d 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -137,6 +137,7 @@ function RowFlags({ flags }: { flags: Flag[] }) {
+ + A snoozed row hides for a day, or until the PR changes. + +
+ clearSnoozes()} + > + Bring back snoozed + + + {snoozedCount} hidden now + +
+ Reset every preference on this browser (theme, filters, muted repos, last-seen marker) back to defaults. This can’t be undone. diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index a1f09d9d..fcc9d718 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -73,7 +73,7 @@ export function QuietButton({ return (
); } @@ -75,6 +82,7 @@ export function CardShell({ {titleLink} {meta} + {rail}
diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index c35c0a5c..6c9a353d 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -134,15 +134,30 @@ function rowFlags(pull: DerivedPull, showIterating: boolean, aging: boolean): Fl } /** - * The flag cluster: the short labels inline for scanning, the plain-English - * meaning one hover away — the same drill-down the CR/QA pips use, so nothing - * on the row hides its meaning behind a native tooltip. + * The row's drill-down: flag labels inline for scanning, the plain-English + * meaning one hover away — the same pattern the CR/QA pips use, so nothing on + * the row hides its meaning behind a native tooltip. In compact density the + * panel also carries the full title and action/context line (the one-line + * layout truncates both), and a flagless row still gets an ellipsis trigger: + * every compact row keeps exactly one recovery point for whatever it clipped. */ -function RowFlags({ flags }: { flags: Flag[] }) { - if (!flags.length) return null; +function RowDetails({ + flags, + title, + note, + compact, +}: { + flags: Flag[]; + title: string; + note: { text: string; tone: 'do' | 'wait' } | null; + compact?: boolean; +}) { + // comfortable rows wrap instead of clipping, so with no flags there's + // nothing to recover and no trigger to show + if (!flags.length && !compact) return null; return ( f.label).join(', ')}`} + aria-label={ + flags.length + ? `row details: ${flags.map(f => f.label).join(', ')}` + : 'row details' + } className="pd-raise -my-2 inline-flex cursor-default items-center gap-2 rounded px-0.5 py-2 hover:bg-secondary/60" > - {flags.map(f => ( - - {f.label} + {flags.length ? ( + flags.map(f => ( + + {f.label} + + )) + ) : ( + + … - ))} + )} )} > + {compact && ( + + {title} + {note && {note.text}} + + )} {flags.map(f => ( )} - - + } + rail={} /> ); } From 09598fd34ce904c357a1565506479de58629acb4 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sat, 18 Jul 2026 12:07:10 -0700 Subject: [PATCH 108/381] Bound the SigPips popover and title every clipped list row The signature-ledger popover had no max width and nowrap content: a long login grew the panel leftward past the viewport edge with no way to scroll it back. Cap it at 300px and let logins break; only the timestamp keeps nowrap. The Settings and Filters lists truncated repo names and logins with no recovery at all, and the filter chip's tooltip was a static string that didn't match its truncated summary. Every truncating list row now carries the full text as its tooltip, and the chip's tooltip is the actual summary. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Filters.tsx | 6 ++++-- frontend-v2/src/components/RepoManager.tsx | 6 +++--- frontend-v2/src/components/bits.tsx | 10 ++++++---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/frontend-v2/src/components/Filters.tsx b/frontend-v2/src/components/Filters.tsx index ad96fd4c..9ac95df4 100644 --- a/frontend-v2/src/components/Filters.tsx +++ b/frontend-v2/src/components/Filters.tsx @@ -139,6 +139,7 @@ export function Filters({ /> )} {shortRepo(name)} @@ -196,7 +197,8 @@ export function Filters({ ? 'border-brand bg-brand-50 text-brand-700' : 'border-line bg-surface text-ink-2 hover:text-brand' }`} - title="filter and hide repos, people, and drafts" + title={summary} + aria-label="filter and hide repos, people, and drafts" > @@ -301,7 +303,7 @@ export function Filters({ } /> - {login} + {login} {count || ''} ))} diff --git a/frontend-v2/src/components/RepoManager.tsx b/frontend-v2/src/components/RepoManager.tsx index 091e870a..5b4dcecc 100644 --- a/frontend-v2/src/components/RepoManager.tsx +++ b/frontend-v2/src/components/RepoManager.tsx @@ -61,7 +61,7 @@ export function RepoManagerGroup({
{muted.map(repo => (
- {repo.name} + {repo.name} {repo.count} onRepoPref(repo.name, null)}>Unmute
@@ -76,7 +76,7 @@ export function RepoManagerGroup({
{orgHiddenRepos.map(repo => (
- {repo.name} + {repo.name} {repo.count} onRepoPref(repo.name, 'show')}> Show for me @@ -117,7 +117,7 @@ export function RepoManagerGroup({ > {isPrimary ? '★' : '☆'} - {repo.name} + {repo.name} {isUserRevealed && ( org-hidden · showing )} diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index fcc9d718..e4fe71c4 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -339,8 +339,10 @@ export function SigPips({ side="right" hover rootClass="relative inline-flex" - width="w-max min-w-[190px]" - panelClass="p-2 text-xs whitespace-nowrap" + // bounded: an unbounded w-max panel + a long login could grow past + // the viewport's left edge (side=right anchors the right edge) + width="w-max min-w-[190px] max-w-[300px]" + panelClass="p-2 text-xs" trigger={t => ( )} > {compact && ( - {title} - {note && {note.text}} + {d.title} + + {fresh && } + + + {pull.sizeKnown && ( + + )} + + {note && {note.text}} )} {flags.map(f => ( @@ -325,6 +352,33 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const note = rowNote(pull, opts.me); // "iterating" and a "fix pushed …" note say the same thing — don't say it twice const showIterating = isIterating(pull) && !note?.text.includes('pushed'); + const details = ( + + ); + + // your move: the imperative IS the signal, so it leads and the badge (which + // would only echo it) steps aside. Otherwise the badge names the state and + // any note just adds who/when. + const lead = + note?.tone === 'do' ? ( + + {note.text} + + ) : ( + + ); return ( - {fresh && } - {/* your move: the imperative IS the signal, so it leads and the - badge (which would only echo it) steps aside. Otherwise the - badge names the state and any note just adds who/when. */} - {note?.tone === 'do' ? ( - - {note.text} - - ) : ( - - )} - - {pull.sizeKnown && ( - - )} - {note?.tone === 'wait' && ( - - {note.text} - - )} - - + opts.compact ? ( + // one line: only the short chips stay inline (fresh, lead, + // repo#). The wait-note, diff size, and full flag list live in + // the details popover, whose trigger sits beside the rail — + // outside this clipping column — so the recovery point survives + // any squeeze. + <> + {fresh && } + {lead} + + + ) : ( + <> + {fresh && } + {lead} + + {pull.sizeKnown && ( + + )} + {note?.tone === 'wait' && ( + + {note.text} + + )} + {details} + + ) + } + rail={ + opts.compact ? ( + <> + {details} + + + ) : ( + + ) } - rail={} /> ); } From 87b6b0cb5bbdb93f527329e3bfe370a6ea0e7414 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sat, 18 Jul 2026 22:38:48 -0700 Subject: [PATCH 110/381] Stop compact chrome from taxing the title in narrow columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even after the title-first squeeze, Classic's ~400px columns showed only eight or nine characters of title. Two standing costs were to blame: - .row-actions hides the copy/snooze/refresh cluster with visibility, which still reserves layout space — every compact row paid ~70px of rail for buttons that only appear on hover. - The inline status badge and repo#number are rigid nowrap chips, so in a narrow column they (not the title) kept the leftover width. In compact the actions now float over the row's tail on hover (absolute, anchored just left of the rail, zero reserved width — hovering no longer shifts the layout either), the status collapses to a colored dot with the label as its tooltip, repo#number moves to the details popover with the rest of the recovery surface, and the trigger's flag label caps at 9ch. Comfortable density is unchanged. Verified on Classic at 385px columns in the dummy board: rail width drops 253px to 173px on every row, zero clipped rails, and visible title length goes from ~9 characters to 19-23. tsc, build, and all 54 tests pass. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Row.tsx | 61 +++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 6be6bea8..01d910b7 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -4,7 +4,17 @@ import { isIterating, lastPushEpoch } from '../model/status'; import { rowNote } from '../model/actions'; import { ago, epoch, pullKey } from '../format'; import { ackPull, isFresh, refreshPull, snoozePull } from '../store'; -import { AgeStamp, DiffSize, FreshTag, RepoRef, SigPips, StatusBadge, WeightMeter } from './bits'; +import { + AgeStamp, + DiffSize, + FreshTag, + RepoRef, + SigPips, + STATUS_DOT, + STATUS_LABEL, + StatusBadge, + WeightMeter, +} from './bits'; import { CardShell } from './Card'; import { Popover } from './Popover'; @@ -187,7 +197,7 @@ function RowDetails({ ) : compact ? ( <> {flags[0].label} @@ -240,12 +250,24 @@ function RowDetails({ ); } -/** Hover/focus actions: copy the branch name, re-fetch from GitHub. */ -function RowActions({ pull }: { pull: DerivedPull }) { +/** + * Hover/focus actions: copy the branch name, snooze, re-fetch from GitHub. + * `overlay` (compact) floats them over the row's tail instead of reserving + * rail width — .row-actions hides them with visibility, which still takes + * layout space, and in a narrow column that standing ~70px tax comes straight + * out of the title. + */ +function RowActions({ pull, overlay }: { pull: DerivedPull; overlay?: boolean }) { const [copied, setCopied] = useState(false); const [spinning, setSpinning] = useState(false); return ( - +
); } diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 01d910b7..085e9fea 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -4,17 +4,7 @@ import { isIterating, lastPushEpoch } from '../model/status'; import { rowNote } from '../model/actions'; import { ago, epoch, pullKey } from '../format'; import { ackPull, isFresh, refreshPull, snoozePull } from '../store'; -import { - AgeStamp, - DiffSize, - FreshTag, - RepoRef, - SigPips, - STATUS_DOT, - STATUS_LABEL, - StatusBadge, - WeightMeter, -} from './bits'; +import { AgeStamp, DiffSize, FreshTag, RepoRef, SigPips, StatusBadge, WeightMeter } from './bits'; import { CardShell } from './Card'; import { Popover } from './Popover'; @@ -144,36 +134,17 @@ function rowFlags(pull: DerivedPull, showIterating: boolean, aging: boolean): Fl } /** - * The row's drill-down: flag labels inline for scanning, the plain-English - * meaning one hover away — the same pattern the CR/QA pips use, so nothing on - * the row hides its meaning behind a native tooltip. In compact density the - * panel is the row's full recovery surface: title, status, repo#, diff size, - * fresh state, and the action/context line all reappear here, because any of - * them can be squeezed out of the one-line layout. Compact's trigger renders - * outside the row's clipping column (beside the rail), so the recovery point - * itself can never be the thing that gets clipped — and it collapses to the - * leading flag plus a count so it stays narrow. + * The flag cluster's drill-down: labels inline for scanning, the plain- + * English meaning one hover away — the same pattern the CR/QA pips use, so + * no flag hides its meaning behind a native tooltip. Both densities show the + * full label list (compact wraps rather than abbreviating); with no flags + * there's nothing to explain and no trigger to show. */ -function RowDetails({ - flags, - pull, - note, - fresh, - compact, -}: { - flags: Flag[]; - pull: DerivedPull; - note: { text: string; tone: 'do' | 'wait' } | null; - fresh: 'new' | 'updated' | null; - compact?: boolean; -}) { - // comfortable rows wrap instead of clipping, so with no flags there's - // nothing to recover and no trigger to show - if (!flags.length && !compact) return null; - const d = pull.data; +function RowDetails({ flags }: { flags: Flag[] }) { + if (!flags.length) return null; return ( f.label).join(', ')}` - : 'row details' - } - className="pd-raise -my-2 inline-flex cursor-default items-center gap-1.5 rounded px-0.5 py-2 hover:bg-secondary/60" + aria-label={`row details: ${flags.map(f => f.label).join(', ')}`} + className="pd-raise -my-2 inline-flex cursor-default flex-wrap items-center gap-x-2 gap-y-1 rounded px-0.5 py-2 hover:bg-secondary/60" > - {!flags.length ? ( - - … + {flags.map(f => ( + + {f.label} - ) : compact ? ( - <> - - {flags[0].label} - - {flags.length > 1 && ( - - +{flags.length - 1} - - )} - - ) : ( - flags.map(f => ( - - {f.label} - - )) - )} + ))} )} > - {compact && ( - - {d.title} - - {fresh && } - - - {pull.sizeKnown && ( - - )} - - {note && {note.text}} - - )} {flags.map(f => ( - ); - - // your move: the imperative IS the signal, so it leads and the badge (which - // would only echo it) steps aside. Otherwise the badge names the state and - // any note just adds who/when. - const lead = - note?.tone === 'do' ? ( - - {note.text} - - ) : ( - - ); - return ( - {fresh && } - - {note?.tone === 'do' && lead} - - ) : ( - <> - {fresh && } - {lead} - - {pull.sizeKnown && ( - - )} - {note?.tone === 'wait' && ( - - {note.text} - - )} - {details} - - ) - } - rail={ - opts.compact ? ( - <> - {details} - - - ) : ( - - ) + // the same untruncated meta in both densities — the shell wraps + // instead of clipping, so no chip ever costs the title its text + <> + {fresh && } + {/* your move: the imperative IS the signal, so it leads and the + badge (which would only echo it) steps aside. Otherwise the + badge names the state and any note just adds who/when. */} + {note?.tone === 'do' ? ( + {note.text} + ) : ( + + )} + + {pull.sizeKnown && ( + + )} + {note?.tone === 'wait' && {note.text}} + + } + rail={} /> ); } From 0f59cec442cdeb3ebd9c6fa778571245afed52b1 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 19 Jul 2026 10:21:43 -0700 Subject: [PATCH 112/381] Tighten motion to snappy state feedback; fix render-perf leaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motion (from a full inventory of every keyframe and transition): - fresh-flash drops 1.4s to 800ms (delay 300 to 200ms) and animates background-color instead of the background shorthand — a socket burst that flashes many rows now settles fast and interpolates one paint property. - spin-once refresh ack drops 600 to 400ms; the settle entrance drops 300 to 240ms with a tighter 30/60/90/120ms stagger; the notice banner drops to 200ms. A big "+N more" reveal stops animating past the first two dozen rows — starting 50+ animation instances in one frame is main-thread work nobody can see. - Every hover state that snapped in a single frame now eases over 150ms like its neighbors: closed rows, the flag-cluster and sign-off ledger triggers, signature list rows, fold summaries, "+N more", the Filters popover rows/buttons, and the v1 link. Interactive triggers get the shared .pressable (which already carries the house 150ms ease-out and press scale); the three inline transitions that defaulted to plain `ease` now use ease-out to match it. Performance (every claim verified in code by the audit): - deriveCached ignored time, so the store's own 60s heartbeat was silently defeated: a quiet pull returned the same cached DerivedPull forever, Row's memo bailed, and ages, "Nm ago" text, and the starved amber/red ramp froze until the pull's next real socket update. A minute bucket in the cache key makes the heartbeat honest. - humans/bots in App are memoized (they rebuilt on every keystroke, settings toggle, and heartbeat); People's rest-lane filter was O(n²) via Array.includes and now uses a Set; the notifications effect scanned every pull per publish even with notifications off and now bails first; ClosedRow is memoized like Row (closed pulls never change, but their folds re-render on every publish). Verified in the dummy board: zero console errors across all four views, 150ms computed transition-durations on the previously snapping hover surfaces, the new flash timing live. tsc, build, and all 54 tests pass. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 8 ++++--- frontend-v2/src/components/ClosedRow.tsx | 16 +++++++++---- frontend-v2/src/components/Filters.tsx | 17 +++++++++----- frontend-v2/src/components/Lane.tsx | 6 ++--- frontend-v2/src/components/Row.tsx | 4 ++-- frontend-v2/src/components/bits.tsx | 6 ++--- frontend-v2/src/notifications.ts | 19 ++++++++++----- frontend-v2/src/store.ts | 23 +++++++++++++----- frontend-v2/src/styles.css | 30 +++++++++++++++--------- frontend-v2/src/views/People.tsx | 3 ++- 10 files changed, 87 insertions(+), 45 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 8e3975f2..9ef6c97d 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -375,8 +375,10 @@ export function App() { snoozed, ]); - const humans = scoped.filter(p => !isBot(p)); - const bots = scoped.filter(isBot); + // memoized so the arrays keep their identity across unrelated re-renders + // (each keystroke, settings toggle, and heartbeat re-runs App) + const humans = useMemo(() => scoped.filter(p => !isBot(p)), [scoped, isBot]); + const bots = useMemo(() => scoped.filter(isBot), [scoped, isBot]); // "did my PR merge over the weekend" is the cheapest answer the board can // give — a slim banner, since the open-PR changes live in the lane const mergedCount = closed.filter(p => (epoch(p.closed_at ?? '') || 0) > lastSeen).length; @@ -490,7 +492,7 @@ export function App() { v1 board diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index 1cae5700..5027abc2 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import type { PullData } from '../types'; import { ago, closedEpoch } from '../format'; import { Avatar, ClosedBadge, PullTitleLink, RepoRef } from './bits'; @@ -6,14 +7,21 @@ import { Avatar, ClosedBadge, PullTitleLink, RepoRef } from './bits'; * Full-width row: the badge, avatar, and right cluster are all flex-none, so * inside a narrow column the title gets ~0px and wraps one character per * line. Columns must use a compact two-zone card instead (Classic's - * ClosedCard). + * ClosedCard). Memoized like Row: closed rows live in folds that re-render + * with every publish, but a closed pull's data never changes again. */ -export function ClosedRow({ pull, lastSeen }: { pull: PullData; lastSeen?: number }) { +export const ClosedRow = memo(function ClosedRow({ + pull, + lastSeen, +}: { + pull: PullData; + lastSeen?: number; +}) { const merged = !!pull.merged_at; const closedAt = closedEpoch(pull); const fresh = lastSeen != null && closedAt > lastSeen; return ( -
+
{fresh && (
); -} +}); diff --git a/frontend-v2/src/components/Filters.tsx b/frontend-v2/src/components/Filters.tsx index 9ac95df4..84fe6dbc 100644 --- a/frontend-v2/src/components/Filters.tsx +++ b/frontend-v2/src/components/Filters.tsx @@ -113,7 +113,10 @@ export function Filters({ ); const repoRow = (name: string, count: number, state: ReturnType) => ( -
+
{state === 'shown' ? ( setRepoPref(name, null)} - className="rounded border-0 bg-transparent px-1 text-[11px] text-ink-3 hover:text-brand" + className="pressable rounded border-0 bg-transparent px-1 text-[11px] text-ink-3 hover:text-brand" > Unmute @@ -167,7 +170,7 @@ export function Filters({ type="button" onClick={() => setRepoPref(name, 'mute')} title={`mute ${shortRepo(name)} — hide it on your board`} - className="rounded border-0 bg-transparent px-1 text-[11px] text-ink-3 hover:text-brand" + className="pressable rounded border-0 bg-transparent px-1 text-[11px] text-ink-3 hover:text-brand" > Mute @@ -275,7 +278,7 @@ export function Filters({ key={t.team} type="button" onClick={() => setScope({ ...scope, authors: [...t.members] })} - className="rounded-lg border border-line bg-surface px-2 py-[3px] text-xs font-medium text-ink-2 hover:border-brand hover:text-brand" + className="pressable rounded-lg border border-line bg-surface px-2 py-[3px] text-xs font-medium text-ink-2 hover:border-brand hover:text-brand" > {t.team} @@ -288,7 +291,7 @@ export function Filters({ .map(([login, count]) => ( ))} diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index c23ab70f..6c09313d 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -151,7 +151,7 @@ export function Truncated({ @@ -177,8 +177,8 @@ export function Fold({ if (!count) return null; return (
- - + + diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 085e9fea..d165ded8 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -155,7 +155,7 @@ function RowDetails({ flags }: { flags: Flag[] }) { {...t} type="button" aria-label={`row details: ${flags.map(f => f.label).join(', ')}`} - className="pd-raise -my-2 inline-flex cursor-default flex-wrap items-center gap-x-2 gap-y-1 rounded px-0.5 py-2 hover:bg-secondary/60" + className="pd-raise pressable -my-2 inline-flex cursor-default flex-wrap items-center gap-x-2 gap-y-1 rounded px-0.5 py-2 hover:bg-secondary/60" > {flags.map(f => ( ackPull(key)} compact={opts.compact} - className={`${flashOnce(key, !!fresh) ? 'row-fresh' : ''} transition-[background-color] duration-150 motion-reduce:transition-none`} + className={`${flashOnce(key, !!fresh) ? 'row-fresh' : ''} transition-[background-color] duration-150 ease-out motion-reduce:transition-none`} meta={ // the same untruncated meta in both densities — the shell wraps // instead of clipping, so no chip ever costs the title its text diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index e4fe71c4..9d4964ee 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -141,7 +141,7 @@ export function Avatar({ fontSize: Math.round(size * 0.42), }; const cls = - 'inline-flex flex-none items-center justify-center rounded-full font-semibold uppercase text-white transition-[scale] duration-150 motion-reduce:transition-none'; + 'inline-flex flex-none items-center justify-center rounded-full font-semibold uppercase text-white transition-[scale] duration-150 ease-out motion-reduce:transition-none'; return onClick ? ( @@ -364,7 +364,7 @@ export function SigPips({ target="_blank" rel="noopener noreferrer" title="open this stamp’s comment on GitHub" - className="flex items-center gap-1.5 rounded px-1 py-[3px] text-ink-2 hover:bg-muted" + className="flex items-center gap-1.5 rounded px-1 py-[3px] text-ink-2 transition-[background-color] duration-150 ease-out hover:bg-muted motion-reduce:transition-none" > {s.data.user.login} diff --git a/frontend-v2/src/notifications.ts b/frontend-v2/src/notifications.ts index b53a96a9..b8d57054 100644 --- a/frontend-v2/src/notifications.ts +++ b/frontend-v2/src/notifications.ts @@ -149,18 +149,25 @@ export function useNotifications(pulls: DerivedPull[], me: string) { }, []); useEffect(() => { + const s = getSettings(); + const active = + notificationsSupported && s.notify && Notification.permission === 'granted' && !!me; + // while off, skip the per-pull scan entirely; dropping primed means the + // first pass after enabling records a baseline silently instead of + // firing a backlog of "new" moves + if (!active) { + primed.current = false; + return; + } + const current = new Map(); for (const p of pulls) { const action = alertMove(p, me); if (action) current.set(pullKey(p.data), action); } - const s = getSettings(); - const active = - notificationsSupported && s.notify && Notification.permission === 'granted' && !!me; - - // record the baseline but stay silent while off, unprimed, or focused - if (!active || !primed.current || document.hasFocus()) { + // record the baseline but stay silent while unprimed or focused + if (!primed.current || document.hasFocus()) { seen.current = current; primed.current = true; return; diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts index 4a205d55..ab932f12 100644 --- a/frontend-v2/src/store.ts +++ b/frontend-v2/src/store.ts @@ -172,25 +172,36 @@ let snapshot: Snapshot = { snoozed: { ...snoozed }, }; -// derive() is pure per (pull, spec, warnDays): cache on reference identity so -// a pullChange for one pull doesn't rebuild 180 DerivedPull objects (and -// re-render 180 memoized rows). warnDays is in the key so changing the aging -// threshold in Settings actually re-derives (starved gates the aging lane). +// derive() is pure per (pull, spec, warnDays, minute): cache on reference +// identity so a pullChange for one pull doesn't rebuild 180 DerivedPull +// objects (and re-render 180 memoized rows). warnDays is in the key so +// changing the aging threshold in Settings actually re-derives (starved gates +// the aging lane). The minute bucket keeps the 60s heartbeat honest: without +// it, a quiet pull returns the same cached object forever, Row's memo bails, +// and ageDays/starved/"Nm ago" freeze at whenever the pull last changed. const derived = new WeakMap< PullData, { spec: RepoSpec | undefined; warnDays: number; weightLabels: ReadonlyMap; + minute: number; value: DerivedPull; } >(); function deriveCached(pull: PullData, spec: RepoSpec | undefined, warnDays: number): DerivedPull { + const minute = Math.floor(Date.now() / 60_000); const hit = derived.get(pull); - if (hit && hit.spec === spec && hit.warnDays === warnDays && hit.weightLabels === weightLabels) + if ( + hit && + hit.spec === spec && + hit.warnDays === warnDays && + hit.weightLabels === weightLabels && + hit.minute === minute + ) return hit.value; const value = derive(pull, spec, Date.now() / 1000, warnDays, weightLabels); - derived.set(pull, { spec, warnDays, weightLabels, value }); + derived.set(pull, { spec, warnDays, weightLabels, minute, value }); return value; } diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 9b4f17c5..35eb8b2a 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -227,16 +227,22 @@ a:hover { the container's flow — borders, hover — unchanged) */ .settle-once > *, .reveal-stagger > * { - animation: settle 300ms var(--ease-quint) backwards; + animation: settle 240ms var(--ease-quint) backwards; } .settle-once > *:nth-child(2), -.reveal-stagger > *:nth-child(2) { animation-delay: 40ms; } +.reveal-stagger > *:nth-child(2) { animation-delay: 30ms; } .settle-once > *:nth-child(3), -.reveal-stagger > *:nth-child(3) { animation-delay: 80ms; } +.reveal-stagger > *:nth-child(3) { animation-delay: 60ms; } .settle-once > *:nth-child(4), -.reveal-stagger > *:nth-child(4) { animation-delay: 120ms; } +.reveal-stagger > *:nth-child(4) { animation-delay: 90ms; } .settle-once > *:nth-child(n + 5), -.reveal-stagger > *:nth-child(n + 5) { animation-delay: 160ms; } +.reveal-stagger > *:nth-child(n + 5) { animation-delay: 120ms; } +/* a big "+N more" can reveal 50+ rows at once; starting that many animation + instances in one frame is real main-thread work for zero perceptible gain — + past the first two dozen the rows just appear */ +.reveal-stagger > *:nth-child(n + 25) { + animation: none; +} @keyframes pop { from { @@ -261,20 +267,22 @@ a:hover { } } .notice-inner { - animation: slide-down 250ms var(--ease-quint); + animation: slide-down 200ms var(--ease-quint); } -/* changed rows: the tick flashes once, then rests */ +/* changed rows: the tick flashes once, then rests. background-color (not the + background shorthand) so the browser interpolates one paint property; kept + under a second so a socket burst that flashes many rows settles fast */ @keyframes fresh-flash { 0% { - background: color-mix(in srgb, var(--brand) 12%, transparent); + background-color: color-mix(in srgb, var(--brand) 12%, transparent); } 100% { - background: transparent; + background-color: transparent; } } .row-fresh { - animation: fresh-flash 1.4s var(--ease-quart) 300ms backwards; + animation: fresh-flash 800ms var(--ease-quart) 200ms backwards; } /* the since-your-last-look marker on a closed row (open rows use FreshTag) */ @@ -381,7 +389,7 @@ a:hover { } } .spin-once { - animation: spin-once 600ms var(--ease-quart); + animation: spin-once 400ms var(--ease-quart); } @keyframes nod { diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx index 611eac16..df456cbc 100644 --- a/frontend-v2/src/views/People.tsx +++ b/frontend-v2/src/views/People.tsx @@ -74,8 +74,9 @@ export function People({ p.data.user.login !== opts.me && (p.crBy.includes(opts.me) || p.recrBy.includes(opts.me)) ); + const inLane = new Set([...reviewable, ...mine]); const rest = theirs - .filter(p => !reviewable.includes(p) && !mine.includes(p)) + .filter(p => !inLane.has(p)) .sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status)); const memberTeam = selectedPerson ? teams.find(t => t.members.includes(selectedPerson))?.team From bb2492c382364df2a1969782a20a0a29d61b0301 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 19 Jul 2026 10:30:39 -0700 Subject: [PATCH 113/381] Grow the Stats lens from four cards to thirteen, in three bands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lens had a status bar, two leaderboards, starvation, and merge-time-by-size — a taste of the board's economics. Everything else the wire already carries went unread. Add nine cards of pure reductions over the same open pool + 14-day closed window, organized into labeled bands so thirteen cards read as a story instead of a wall: - Flow (last 14 days): Shipping (merges per day as a mini column chart, this-week-vs-last delta, and median time to FIRST CR — the responsiveness number time-to-merge hides), Review pulse (CR+QA stamps per day, the input-side twin), and the existing merge-by-size. - The board right now: Review debt (CR/QA stamps still owed, re-stamps a push invalidated, unclaimed QA), age mix (bucketed with the same amber/red thresholds the rows use), review-effort mix by weight, Friction (conflicts, blocks, CI red, drafts — stuck on something no stamp will fix), open PRs by author, and by repo (with each pile's awaiting-CR share and oldest age). - People: the two leaderboards, starvation, and new Give-and-take (distinct PRs you reviewed vs stamps you received, self-stamps excluded — the review-economy balance). All ten new reductions live in model/stats.ts with unit tests (54→64). The mini column chart and the XS→XL weight ramp move into stats/parts as shared pieces (MergeSizeCard's local copy deleted). The dummy backend re-dated pulls into the present but left signature timestamps frozen at fixture age, so every stamp-timeline stat read empty in dummy mode; it now spreads signatures across each pull's lifetime the same way it re-dates the pulls themselves. Verified in the dummy board: all thirteen cards render with live numbers, zero console errors, zero truncated text. tsc, build, and all 64 tests pass. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/backend/dummy.ts | 24 ++ frontend-v2/src/model/stats.test.ts | 249 +++++++++++++++- frontend-v2/src/model/stats.ts | 267 ++++++++++++++++++ frontend-v2/src/views/Stats.tsx | 91 +++++- frontend-v2/src/views/stats/AgeMixCard.tsx | 42 +++ .../src/views/stats/AuthorLoadCard.tsx | 40 +++ frontend-v2/src/views/stats/DebtCard.tsx | 44 +++ frontend-v2/src/views/stats/EffortMixCard.tsx | 34 +++ frontend-v2/src/views/stats/FrictionCard.tsx | 64 +++++ frontend-v2/src/views/stats/MergeSizeCard.tsx | 11 +- frontend-v2/src/views/stats/PulseCard.tsx | 28 ++ .../src/views/stats/ReciprocityCard.tsx | 48 ++++ frontend-v2/src/views/stats/RepoLoadCard.tsx | 42 +++ .../src/views/stats/ThroughputCard.tsx | 52 ++++ frontend-v2/src/views/stats/parts.tsx | 43 +++ 15 files changed, 1059 insertions(+), 20 deletions(-) create mode 100644 frontend-v2/src/views/stats/AgeMixCard.tsx create mode 100644 frontend-v2/src/views/stats/AuthorLoadCard.tsx create mode 100644 frontend-v2/src/views/stats/DebtCard.tsx create mode 100644 frontend-v2/src/views/stats/EffortMixCard.tsx create mode 100644 frontend-v2/src/views/stats/FrictionCard.tsx create mode 100644 frontend-v2/src/views/stats/PulseCard.tsx create mode 100644 frontend-v2/src/views/stats/ReciprocityCard.tsx create mode 100644 frontend-v2/src/views/stats/RepoLoadCard.tsx create mode 100644 frontend-v2/src/views/stats/ThroughputCard.tsx diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts index c26494a7..959ced04 100644 --- a/frontend-v2/src/backend/dummy.ts +++ b/frontend-v2/src/backend/dummy.ts @@ -30,6 +30,28 @@ export async function loadDummy(): Promise { }; } +// The fixture's signature timestamps are as frozen as its pulls; spread them +// across the given window so stamp-timeline stats (review pulse, first-CR +// latency) exercise realistically instead of reading a decade-old wall. +function redateSigs( + status: PullData['status'], + startMs: number, + endMs: number +): PullData['status'] { + const span = Math.max(endMs - startMs, 3_600_000); + const shift = (sigs: S[], salt: number): S[] => + sigs.map((s, j) => ({ + ...s, + data: { + ...s.data, + created_at: new Date( + startMs + ((j + 1) * span) / (sigs.length + 1) + salt + ).toISOString(), + }, + })); + return { ...status, allCR: shift(status.allCR, 0), allQA: shift(status.allQA, 600_000) }; +} + function redate(pull: PullData, i: number): PullData { const now = Date.now(); const ageDays = (i * 7919) % 45; // deterministic spread, 0-45 days @@ -43,6 +65,7 @@ function redate(pull: PullData, i: number): PullData { ...pull, created_at: created.toISOString(), updated_at: updated.toISOString(), + status: redateSigs(pull.status, created.getTime(), now), additions, deletions, }; @@ -67,6 +90,7 @@ function synthMerged(pulls: PullData[]): PullData[] { updated_at: new Date(merged).toISOString(), closed_at: new Date(merged).toISOString(), merged_at: new Date(merged).toISOString(), + status: redateSigs(p.status, created, merged), }; }); } diff --git a/frontend-v2/src/model/stats.test.ts b/frontend-v2/src/model/stats.test.ts index 9025dd62..66f3051b 100644 --- a/frontend-v2/src/model/stats.test.ts +++ b/frontend-v2/src/model/stats.test.ts @@ -1,7 +1,22 @@ import { describe, expect, it } from 'vitest'; import type { PullData } from '../types'; -import type { DerivedPull, Status } from './status'; -import { crStarvation, humanHours, mergeTimeBySize, signoffLeaders } from './stats'; +import type { DerivedPull, Status, Weight } from './status'; +import { + ageMix, + authorLoad, + crStarvation, + effortMix, + firstCrLatency, + friction, + humanHours, + mergedPerDay, + mergeTimeBySize, + reciprocity, + repoBreakdown, + reviewDebt, + signoffLeaders, + stampsPerDay, +} from './stats'; function sig(login: string) { return { data: { user: { login } } }; @@ -113,3 +128,233 @@ describe('humanHours', () => { expect(humanHours(50)).toBe('2.1d'); }); }); + +// a midday anchor so hour arithmetic never crosses a local midnight by accident +const NOON = new Date(2026, 6, 18, 12, 0, 0).getTime(); +const HOUR = 3_600_000; + +function sigAt(login: string, t: number) { + return { data: { user: { login }, created_at: new Date(t).toISOString() } }; +} + +/** a DerivedPull with every field the newer reductions read. */ +function full(over: { + status?: Status; + author?: string; + repo?: string; + n?: number; + ageDays?: number; + weight?: Weight; + sizeKnown?: boolean; + crHave?: number; + qaHave?: number; + crReq?: number; + qaReq?: number; + recrBy?: string[]; + reqaBy?: string[]; + qaing?: string | null; + conflict?: boolean; + deployBlockedBy?: string[]; + devBlockedBy?: string[]; + dependent?: boolean; + externalBlock?: boolean; + allCR?: { data: { user: { login: string }; created_at?: string } }[]; + allQA?: { data: { user: { login: string }; created_at?: string } }[]; +}): DerivedPull { + return { + status: over.status ?? 'needs_cr', + ageDays: over.ageDays ?? 0, + weight: over.weight ?? 'M', + sizeKnown: over.sizeKnown ?? true, + crHave: over.crHave ?? 0, + qaHave: over.qaHave ?? 0, + recrBy: over.recrBy ?? [], + reqaBy: over.reqaBy ?? [], + qaingLogin: over.qaing ?? null, + conflict: over.conflict ?? false, + deployBlockedBy: over.deployBlockedBy ?? [], + devBlockedBy: over.devBlockedBy ?? [], + dependent: over.dependent ?? false, + externalBlock: over.externalBlock ?? false, + data: { + repo: over.repo ?? 'iFixit/ifixit', + number: over.n ?? 1, + user: { login: over.author ?? 'alice' }, + status: { + cr_req: over.crReq ?? 2, + qa_req: over.qaReq ?? 1, + allCR: over.allCR ?? [], + allQA: over.allQA ?? [], + }, + }, + } as unknown as DerivedPull; +} + +describe('mergedPerDay', () => { + it('buckets merges into local days, keeping empty days, oldest first', () => { + const closed = [ + { merged_at: new Date(NOON - HOUR).toISOString() }, // today + { merged_at: new Date(NOON - 25 * HOUR).toISOString() }, // yesterday + { merged_at: new Date(NOON - 25 * HOUR).toISOString() }, // yesterday + { merged_at: null }, // closed unmerged, ignored + { merged_at: new Date(NOON - 40 * 24 * HOUR).toISOString() }, // out of window + ] as unknown as PullData[]; + const days = mergedPerDay(closed, 7, NOON); + expect(days).toHaveLength(7); + expect(days[6].count).toBe(1); // today is last + expect(days[5].count).toBe(2); + expect(days.reduce((a, d) => a + d.count, 0)).toBe(3); + }); +}); + +describe('stampsPerDay', () => { + it('counts every CR and QA stamp across open and closed, per day', () => { + const pulls = [ + full({ + allCR: [sigAt('bob', NOON - HOUR), sigAt('bob', NOON - 26 * HOUR)], + allQA: [sigAt('dave', NOON - 2 * HOUR)], + }), + ]; + const closed = [ + { status: { allCR: [sigAt('carol', NOON - 3 * HOUR)], allQA: [] } }, + ] as unknown as PullData[]; + const days = stampsPerDay(pulls, closed, 3, NOON); + expect(days[2].count).toBe(3); // today: bob, dave, carol + expect(days[1].count).toBe(1); // yesterday: bob's earlier stamp + }); +}); + +describe('ageMix', () => { + it('buckets open pulls by age with inclusive upper edges', () => { + const mix = ageMix([ + full({ ageDays: 0 }), + full({ ageDays: 2 }), + full({ ageDays: 3 }), + full({ ageDays: 13 }), + full({ ageDays: 30 }), + ]); + expect(mix.map(b => b.count)).toEqual([1, 1, 1, 1, 1]); + expect(mix[0].label).toBe('today'); + expect(mix[4].label).toBe('14d+'); + }); +}); + +describe('effortMix', () => { + it('counts open pulls per weight, keeping zero classes, tracking estimates', () => { + const mix = effortMix([ + full({ weight: 'XS' }), + full({ weight: 'XS', sizeKnown: false }), + full({ weight: 'XL' }), + ]); + expect(mix.buckets.map(b => b.count)).toEqual([2, 0, 0, 0, 1]); + expect(mix.estimated).toBe(1); + }); +}); + +describe('reviewDebt', () => { + it('sums missing stamps, restamps owed, and unclaimed QA', () => { + const debt = reviewDebt([ + full({ status: 'needs_cr', crReq: 2, crHave: 0 }), // 2 CR slots + full({ status: 'needs_recr', crReq: 2, crHave: 1, recrBy: ['bob'] }), // 1 slot + 1 restamp + full({ status: 'needs_qa', qaReq: 1, qaHave: 0 }), // 1 QA slot, unclaimed + full({ status: 'needs_qa', qaReq: 1, qaHave: 0, qaing: 'dave' }), // claimed + full({ status: 'needs_qa', qaReq: 1, qaHave: 0, reqaBy: ['eve'] }), // re-QA owed + full({ status: 'ready' }), // no debt + ]); + expect(debt).toEqual({ crSlots: 3, qaSlots: 3, restamps: 2, unclaimedQa: 1 }); + }); +}); + +describe('firstCrLatency', () => { + it('measures created→first CR on merged pulls only', () => { + const closed = [ + { + merged_at: new Date(NOON).toISOString(), + created_at: new Date(NOON - 10 * HOUR).toISOString(), + status: { + allCR: [sigAt('bob', NOON - 6 * HOUR), sigAt('carol', NOON - 2 * HOUR)], + allQA: [], + }, + }, // first CR after 4h + { + merged_at: new Date(NOON).toISOString(), + created_at: new Date(NOON - 10 * HOUR).toISOString(), + status: { allCR: [sigAt('bob', NOON - 4 * HOUR)], allQA: [] }, + }, // 6h + { + merged_at: null, + created_at: new Date(NOON - 10 * HOUR).toISOString(), + status: { allCR: [sigAt('bob', NOON)], allQA: [] }, + }, // unmerged, ignored + ] as unknown as PullData[]; + const lat = firstCrLatency(closed); + expect(lat.sampled).toBe(2); + expect(lat.medianHours).toBe(5); + expect(lat.avgHours).toBe(5); + }); +}); + +describe('reciprocity', () => { + it('pairs distinct PRs reviewed against stamps received, ignoring self-stamps', () => { + const pulls = [ + full({ author: 'alice', n: 1, allCR: [sigAt('bob', NOON), sigAt('alice', NOON)] }), + full({ + author: 'alice', + n: 2, + allCR: [sigAt('bob', NOON)], + allQA: [sigAt('carol', NOON)], + }), + ]; + const rows = reciprocity(pulls, []); + expect(rows.find(r => r.login === 'bob')).toMatchObject({ given: 2, received: 0 }); + expect(rows.find(r => r.login === 'carol')).toMatchObject({ given: 1, received: 0 }); + // alice's self-stamp doesn't count anywhere; she received bob×2 + carol×1 + expect(rows.find(r => r.login === 'alice')).toMatchObject({ given: 0, received: 3 }); + }); +}); + +describe('authorLoad', () => { + it('counts open PRs per author with their oldest', () => { + const rows = authorLoad([ + full({ author: 'alice', n: 1, ageDays: 3 }), + full({ author: 'alice', n: 2, ageDays: 9 }), + full({ author: 'bob', n: 3, ageDays: 1 }), + ]); + expect(rows[0]).toEqual({ login: 'alice', count: 2, oldestDays: 9 }); + expect(rows[1]).toEqual({ login: 'bob', count: 1, oldestDays: 1 }); + }); +}); + +describe('repoBreakdown', () => { + it('splits each repo pile into total, awaiting-CR, and oldest', () => { + const rows = repoBreakdown([ + full({ repo: 'iFixit/a', status: 'needs_cr', ageDays: 5 }), + full({ repo: 'iFixit/a', status: 'ready', ageDays: 2 }), + full({ repo: 'iFixit/b', status: 'needs_recr', ageDays: 1 }), + ]); + expect(rows[0]).toEqual({ repo: 'iFixit/a', count: 2, awaitingCr: 1, oldestDays: 5 }); + expect(rows[1]).toEqual({ repo: 'iFixit/b', count: 1, awaitingCr: 1, oldestDays: 1 }); + }); +}); + +describe('friction', () => { + it('counts each stuck condition independently', () => { + const f = friction([ + full({ conflict: true, dependent: true }), + full({ status: 'dev_block' }), + full({ devBlockedBy: ['bob'] }), + full({ status: 'ci_red' }), + full({ status: 'draft' }), + full({ deployBlockedBy: ['carol'], externalBlock: true }), + ]); + expect(f).toEqual({ + conflicts: 1, + deployBlocked: 1, + devBlocked: 2, + ciRed: 1, + drafts: 1, + stacked: 1, + external: 1, + }); + }); +}); diff --git a/frontend-v2/src/model/stats.ts b/frontend-v2/src/model/stats.ts index b1706e62..25174862 100644 --- a/frontend-v2/src/model/stats.ts +++ b/frontend-v2/src/model/stats.ts @@ -145,6 +145,273 @@ function median(xs: number[]): number { return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; } +const DAY_MS = 86_400_000; +function startOfDay(t: number): number { + return new Date(t).setHours(0, 0, 0, 0); +} + +export interface DayCount { + /** local-midnight epoch ms identifying the day */ + day: number; + count: number; +} + +function dayBuckets(days: number, now: number): DayCount[] { + const start = startOfDay(now) - (days - 1) * DAY_MS; + return Array.from({ length: days }, (_, i) => ({ day: start + i * DAY_MS, count: 0 })); +} +function bump(buckets: DayCount[], t: number) { + // round, not floor: a DST shift makes a local day 23 or 25 hours long + const i = Math.round((startOfDay(t) - buckets[0].day) / DAY_MS); + if (i >= 0 && i < buckets.length) buckets[i].count += 1; +} + +/** merges per local day over the trailing window, oldest first, empty days kept. */ +export function mergedPerDay(closed: PullData[], days: number, now: number): DayCount[] { + const buckets = dayBuckets(days, now); + for (const d of closed) { + if (!d.merged_at) continue; + bump(buckets, Date.parse(d.merged_at)); + } + return buckets; +} + +/** + * CR+QA stamps landed per local day — the board's review pulse. Counts every + * signature (a re-stamp is new review work), across open pulls and the closed + * window, so a day reads "how much reviewing happened", not "how much survived". + */ +export function stampsPerDay( + pulls: DerivedPull[], + closed: PullData[], + days: number, + now: number +): DayCount[] { + const buckets = dayBuckets(days, now); + const add = (d: PullData) => { + for (const s of [...d.status.allCR, ...d.status.allQA]) { + const t = Date.parse(String(s.data.created_at)); + if (Number.isFinite(t)) bump(buckets, t); + } + }; + for (const p of pulls) add(p.data); + for (const d of closed) add(d); + return buckets; +} + +export interface LabeledCount { + label: string; + count: number; +} + +/** open pulls by how long they've been open, youngest bucket first. */ +export function ageMix(pulls: DerivedPull[]): LabeledCount[] { + const buckets = [ + { label: 'today', count: 0, max: 0 }, + { label: '1–2d', count: 0, max: 2 }, + { label: '3–6d', count: 0, max: 6 }, + { label: '7–13d', count: 0, max: 13 }, + { label: '14d+', count: 0, max: Infinity }, + ]; + for (const p of pulls) buckets.find(b => p.ageDays <= b.max)!.count += 1; + return buckets.map(({ label, count }) => ({ label, count })); +} + +export interface EffortMix { + /** exactly 5 entries, XS→XL order, zero counts kept for scale */ + buckets: { weight: Weight; count: number }[]; + /** how many open pulls carry no wire size (their weight is a guess) */ + estimated: number; +} + +/** the open board's review effort, by weight class. */ +export function effortMix(pulls: DerivedPull[]): EffortMix { + const by = new Map(WEIGHTS.map(w => [w, 0])); + let estimated = 0; + for (const p of pulls) { + by.set(p.weight, by.get(p.weight)! + 1); + if (!p.sizeKnown) estimated += 1; + } + return { buckets: WEIGHTS.map(weight => ({ weight, count: by.get(weight)! })), estimated }; +} + +export interface ReviewDebt { + /** CR stamps still needed across CR-incomplete pulls */ + crSlots: number; + /** QA stamps still needed across pulls at the QA gate */ + qaSlots: number; + /** stamps a push invalidated that haven't been refreshed (re-CR + re-QA) */ + restamps: number; + /** needs-QA pulls nobody has claimed or owes a re-QA on */ + unclaimedQa: number; +} + +/** + * What the board is owed, in stamps: the reviewer-hours of standing debt. The + * restamp count is the cheapest debt to clear — the reviewer already knows + * the code. + */ +export function reviewDebt(pulls: DerivedPull[]): ReviewDebt { + const debt = { crSlots: 0, qaSlots: 0, restamps: 0, unclaimedQa: 0 }; + for (const p of pulls) { + if (['needs_cr', 'needs_recr'].includes(p.status)) + debt.crSlots += Math.max(p.data.status.cr_req - p.crHave, 0); + if (p.status === 'needs_qa') { + debt.qaSlots += Math.max(p.data.status.qa_req - p.qaHave, 0); + if (!p.qaingLogin && !p.reqaBy.length) debt.unclaimedQa += 1; + } + debt.restamps += p.recrBy.length + p.reqaBy.length; + } + return debt; +} + +export interface Latency { + medianHours: number; + avgHours: number; + /** merged pulls that had a first CR stamp with a usable timestamp */ + sampled: number; +} + +/** + * How long a merged PR waited for its FIRST CR — the responsiveness number, + * distinct from time-to-merge (which includes the author's own iteration). + */ +export function firstCrLatency(closed: PullData[]): Latency { + const hours: number[] = []; + for (const d of closed) { + if (!d.merged_at || !d.status.allCR.length) continue; + const first = Math.min( + ...d.status.allCR.map(s => Date.parse(String(s.data.created_at)) || Infinity) + ); + const h = (first - Date.parse(d.created_at)) / 3_600_000; + if (Number.isFinite(h) && h > 0) hours.push(h); + } + return { + medianHours: median(hours), + avgHours: hours.length ? hours.reduce((a, b) => a + b, 0) / hours.length : 0, + sampled: hours.length, + }; +} + +export interface Reciprocity { + login: string; + /** distinct PRs this person stamped (CR or QA) for someone else */ + given: number; + /** stamps others put on this person's authored PRs */ + received: number; +} + +/** + * Review give-and-take per person: what you stamped for others against what + * others stamped for you. A board where the same few people are all "given" + * and everyone else is all "received" has a review economy problem. + */ +export function reciprocity(pulls: DerivedPull[], closed: PullData[]): Reciprocity[] { + const given = new Map>(); + const received = new Map(); + const add = (d: PullData) => { + const key = `${d.repo}#${d.number}`; + for (const s of [...d.status.allCR, ...d.status.allQA]) { + const login = s.data.user.login; + if (login === d.user.login) continue; // self-stamps aren't review economy + if (!given.has(login)) given.set(login, new Set()); + given.get(login)!.add(key); + received.set(d.user.login, (received.get(d.user.login) ?? 0) + 1); + } + }; + for (const p of pulls) add(p.data); + for (const d of closed) add(d); + const logins = new Set([...given.keys(), ...received.keys()]); + return [...logins] + .map(login => ({ + login, + given: given.get(login)?.size ?? 0, + received: received.get(login) ?? 0, + })) + .sort( + (a, b) => b.given - a.given || b.received - a.received || a.login.localeCompare(b.login) + ); +} + +export interface AuthorLoad { + login: string; + count: number; + oldestDays: number; +} + +/** who has the most open PRs on the board (work-in-progress load). */ +export function authorLoad(pulls: DerivedPull[]): AuthorLoad[] { + const by = new Map(); + for (const p of pulls) { + const cur = by.get(p.data.user.login) ?? { count: 0, oldestDays: 0 }; + cur.count += 1; + cur.oldestDays = Math.max(cur.oldestDays, p.ageDays); + by.set(p.data.user.login, cur); + } + return [...by.entries()] + .map(([login, v]) => ({ login, ...v })) + .sort( + (a, b) => + b.count - a.count || b.oldestDays - a.oldestDays || a.login.localeCompare(b.login) + ); +} + +export interface RepoLoad { + repo: string; + count: number; + /** how many of those still need CR (the reviewer-facing share) */ + awaitingCr: number; + oldestDays: number; +} + +/** where the open PRs live, and how much of each repo's pile is review work. */ +export function repoBreakdown(pulls: DerivedPull[]): RepoLoad[] { + const by = new Map(); + for (const p of pulls) { + const cur = by.get(p.data.repo) ?? { count: 0, awaitingCr: 0, oldestDays: 0 }; + cur.count += 1; + if (['needs_cr', 'needs_recr'].includes(p.status)) cur.awaitingCr += 1; + cur.oldestDays = Math.max(cur.oldestDays, p.ageDays); + by.set(p.data.repo, cur); + } + return [...by.entries()] + .map(([repo, v]) => ({ repo, ...v })) + .sort((a, b) => b.count - a.count || a.repo.localeCompare(b.repo)); +} + +export interface Friction { + conflicts: number; + deployBlocked: number; + devBlocked: number; + ciRed: number; + drafts: number; + stacked: number; + external: number; +} + +/** everything currently stuck on something other than review attention. */ +export function friction(pulls: DerivedPull[]): Friction { + const f = { + conflicts: 0, + deployBlocked: 0, + devBlocked: 0, + ciRed: 0, + drafts: 0, + stacked: 0, + external: 0, + }; + for (const p of pulls) { + if (p.conflict) f.conflicts += 1; + if (p.deployBlockedBy.length) f.deployBlocked += 1; + if (p.devBlockedBy.length || p.status === 'dev_block') f.devBlocked += 1; + if (p.status === 'ci_red') f.ciRed += 1; + if (p.status === 'draft') f.drafts += 1; + if (p.dependent) f.stacked += 1; + if (p.externalBlock) f.external += 1; + } + return f; +} + /** "3h" / "1.4d" / "12d" — durations for the merge-time bars. */ export function humanHours(h: number): string { if (h < 1) return `${Math.round(h * 60)}m`; diff --git a/frontend-v2/src/views/Stats.tsx b/frontend-v2/src/views/Stats.tsx index c52ac068..eb1024a6 100644 --- a/frontend-v2/src/views/Stats.tsx +++ b/frontend-v2/src/views/Stats.tsx @@ -1,16 +1,58 @@ -import { useMemo } from 'react'; +import { useMemo, type ReactNode } from 'react'; import type { DerivedPull } from '../model/status'; -import { crStarvation, mergeTimeBySize, signoffLeaders, statusBreakdown } from '../model/stats'; +import { + ageMix, + authorLoad, + crStarvation, + effortMix, + firstCrLatency, + friction, + mergedPerDay, + mergeTimeBySize, + reciprocity, + repoBreakdown, + reviewDebt, + signoffLeaders, + stampsPerDay, + statusBreakdown, +} from '../model/stats'; import { useSettings } from '../settings'; import type { PullData } from '../types'; import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; +import { AgeMixCard } from './stats/AgeMixCard'; +import { AuthorLoadCard } from './stats/AuthorLoadCard'; +import { DebtCard } from './stats/DebtCard'; +import { EffortMixCard } from './stats/EffortMixCard'; +import { FrictionCard } from './stats/FrictionCard'; import { Leaderboard } from './stats/Leaderboard'; import { MergeSizeCard } from './stats/MergeSizeCard'; +import { PulseCard } from './stats/PulseCard'; +import { ReciprocityCard } from './stats/ReciprocityCard'; +import { RepoLoadCard } from './stats/RepoLoadCard'; import { StarvationCard } from './stats/StarvationCard'; import { StatusBar } from './stats/StatusBar'; +import { ThroughputCard } from './stats/ThroughputCard'; + +const WINDOW_DAYS = 14; + +/** a labeled band of cards; the grid reflows within each band. */ +function Group({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
+ {children} +
+
+ ); +} /** - * The Stats lens: the board's shape and its review economics at a glance. + * The Stats lens: the board's shape and its review economics at a glance, + * in three bands — Flow (what the last two weeks produced), the board right + * now (what's open and what it's owed), and People (who's carrying it). * Everything is a snapshot of the current pool (open PRs plus the 14-day * closed window the server keeps), so it moves with the scope and filter — * scope to a team and these become that team's numbers. @@ -36,10 +78,26 @@ export function Stats({ })), [pulls] ); + // flow — the closed window plus stamp timestamps + const perDay = useMemo(() => mergedPerDay(closed, WINDOW_DAYS, Date.now()), [closed]); + const firstCr = useMemo(() => firstCrLatency(closed), [closed]); + const pulse = useMemo( + () => stampsPerDay(pulls, closed, WINDOW_DAYS, Date.now()), + [pulls, closed] + ); + const merge = useMemo(() => mergeTimeBySize(closed), [closed]); + // the board right now + const debt = useMemo(() => reviewDebt(pulls), [pulls]); + const ages = useMemo(() => ageMix(pulls), [pulls]); + const effort = useMemo(() => effortMix(pulls), [pulls]); + const stuck = useMemo(() => friction(pulls), [pulls]); + const authors = useMemo(() => authorLoad(pulls), [pulls]); + const repos = useMemo(() => repoBreakdown(pulls), [pulls]); + // people const crLeaders = useMemo(() => signoffLeaders(pulls, closed, 'CR'), [pulls, closed]); const qaLeaders = useMemo(() => signoffLeaders(pulls, closed, 'QA'), [pulls, closed]); + const giveTake = useMemo(() => reciprocity(pulls, closed), [pulls, closed]); const starved = useMemo(() => crStarvation(pulls), [pulls]); - const merge = useMemo(() => mergeTimeBySize(closed), [closed]); const settings = useSettings(); if (!pulls.length && !closed.length) { @@ -49,9 +107,26 @@ export function Stats({ } return ( -
+
-
+ + + + + + + + + + + + + + + - -
+
); } diff --git a/frontend-v2/src/views/stats/AgeMixCard.tsx b/frontend-v2/src/views/stats/AgeMixCard.tsx new file mode 100644 index 00000000..7ef50048 --- /dev/null +++ b/frontend-v2/src/views/stats/AgeMixCard.tsx @@ -0,0 +1,42 @@ +import type { LabeledCount } from '../../model/stats'; +import { BarRow, StatsCard } from './parts'; + +/** + * How old the open board is, youngest bucket first. Color follows the same + * fresh→amber→red read as the rows' age stamps, so a bottom-heavy chart here + * and a wall of red ages on the board are the same fact. + */ +export function AgeMixCard({ + buckets, + warnDays, + rotDays, +}: { + buckets: LabeledCount[]; + warnDays: number; + rotDays: number; +}) { + const max = Math.max(...buckets.map(b => b.count), 1); + // bucket upper bounds, mirroring ageMix's edges + const edges = [0, 2, 6, 13, Infinity]; + const color = (i: number) => + edges[i] >= rotDays ? 'var(--bad)' : edges[i] >= warnDays ? 'var(--warn)' : 'var(--ok)'; + return ( + +
+ {buckets.map((b, i) => ( + {b.label}} + trail={ + + {b.count || ''} + + } + /> + ))} +
+
+ ); +} diff --git a/frontend-v2/src/views/stats/AuthorLoadCard.tsx b/frontend-v2/src/views/stats/AuthorLoadCard.tsx new file mode 100644 index 00000000..1b8134d6 --- /dev/null +++ b/frontend-v2/src/views/stats/AuthorLoadCard.tsx @@ -0,0 +1,40 @@ +import type { AuthorLoad } from '../../model/stats'; +import { BarRow, PersonCell, StatsCard } from './parts'; + +/** who's carrying the most open PRs, with each person's oldest one alongside. */ +export function AuthorLoadCard({ + rows, + me, + onPerson, + cap = 10, +}: { + rows: AuthorLoad[]; + me?: string; + onPerson?: (login: string) => void; + cap?: number; +}) { + const shown = rows.slice(0, cap); + const max = Math.max(...rows.map(r => r.count), 1); + const more = rows.length - shown.length; + return ( + +
+ {shown.map(r => ( + } + trail={ + + {r.count} + · oldest {r.oldestDays}d + + } + /> + ))} + {more > 0 &&
+{more} more authors
} +
+
+ ); +} diff --git a/frontend-v2/src/views/stats/DebtCard.tsx b/frontend-v2/src/views/stats/DebtCard.tsx new file mode 100644 index 00000000..ab7afc2a --- /dev/null +++ b/frontend-v2/src/views/stats/DebtCard.tsx @@ -0,0 +1,44 @@ +import type { ReviewDebt } from '../../model/stats'; +import { StatsCard } from './parts'; + +/** + * The board's standing review debt, in stamps. Re-stamps get the amber tint + * when nonzero: they're the cheapest debt on the board (the reviewer already + * knows the code) and the most commonly forgotten. + */ +export function DebtCard({ debt }: { debt: ReviewDebt }) { + const cell = (value: number, label: string, hint: string, hot?: boolean) => ( +
+
+ {value} +
+
{label}
+
+ ); + return ( + +
+ {cell( + debt.crSlots, + 'CR stamps needed', + 'CR sign-offs still missing across CR-incomplete PRs' + )} + {cell( + debt.qaSlots, + 'QA stamps needed', + 'QA sign-offs still missing across PRs at the QA gate' + )} + {cell( + debt.restamps, + 're-stamps owed', + 'stamps a push invalidated — the reviewer already knows the code', + true + )} + {cell(debt.unclaimedQa, 'QA unclaimed', 'needs-QA PRs nobody has picked up')} +
+
+ ); +} diff --git a/frontend-v2/src/views/stats/EffortMixCard.tsx b/frontend-v2/src/views/stats/EffortMixCard.tsx new file mode 100644 index 00000000..922b9f26 --- /dev/null +++ b/frontend-v2/src/views/stats/EffortMixCard.tsx @@ -0,0 +1,34 @@ +import type { EffortMix } from '../../model/stats'; +import { BarRow, StatsCard, WEIGHT_RAMP } from './parts'; + +/** + * The open board's review effort by weight class — is the pile mostly quick + * reads or heavy lifts? Same XS→XL ramp as the rows' weight meters. + */ +export function EffortMixCard({ mix }: { mix: EffortMix }) { + const max = Math.max(...mix.buckets.map(b => b.count), 1); + return ( + +
+ {mix.buckets.map(b => ( + {b.weight}} + trail={ + + {b.count || ''} + + } + /> + ))} +
+ {mix.estimated > 0 && ( +
+ {mix.estimated} without a wire size (weight estimated) +
+ )} +
+ ); +} diff --git a/frontend-v2/src/views/stats/FrictionCard.tsx b/frontend-v2/src/views/stats/FrictionCard.tsx new file mode 100644 index 00000000..d0880669 --- /dev/null +++ b/frontend-v2/src/views/stats/FrictionCard.tsx @@ -0,0 +1,64 @@ +import type { Friction } from '../../model/stats'; +import { StatsCard } from './parts'; + +const ROWS: { key: keyof Friction; label: string; dot: string; hint: string }[] = [ + { + key: 'conflicts', + label: 'merge conflicts', + dot: 'var(--warn)', + hint: 'the author needs to rebase', + }, + { + key: 'devBlocked', + label: 'dev blocked', + dot: 'var(--warn)', + hint: 'feedback waiting on the author', + }, + { key: 'ciRed', label: 'CI red', dot: 'var(--bad)', hint: 'a required check is failing' }, + { + key: 'deployBlocked', + label: 'deploy blocked', + dot: 'var(--ink-3)', + hint: 'done, deliberately not shipped', + }, + { + key: 'external', + label: 'external block', + dot: 'var(--warn)', + hint: 'stuck on something outside the repo', + }, + { key: 'stacked', label: 'stacked', dot: 'var(--ink-3)', hint: 'lands with a parent branch' }, + { key: 'drafts', label: 'drafts', dot: 'var(--border)', hint: 'not ready for review yet' }, +]; + +/** + * Everything stuck on something other than review attention — the part of the + * board no amount of stamping will move. Zero rows stay visible but dimmed: + * "no conflicts" is information. + */ +export function FrictionCard({ friction }: { friction: Friction }) { + return ( + +
+ {ROWS.map(r => { + const n = friction[r.key]; + return ( +
+ + {r.label} + {n} +
+ ); + })} +
+
+ ); +} diff --git a/frontend-v2/src/views/stats/MergeSizeCard.tsx b/frontend-v2/src/views/stats/MergeSizeCard.tsx index f50f010a..ee314600 100644 --- a/frontend-v2/src/views/stats/MergeSizeCard.tsx +++ b/frontend-v2/src/views/stats/MergeSizeCard.tsx @@ -1,14 +1,5 @@ -import type { Weight } from '../../model/status'; import { humanHours, type MergeBucket } from '../../model/stats'; -import { BarRow, StatsCard } from './parts'; - -const RAMP: Record = { - XS: 'var(--ok)', - S: 'var(--ok)', - M: 'var(--ink-3)', - L: 'var(--warn)', - XL: 'var(--bad)', -}; +import { BarRow, StatsCard, WEIGHT_RAMP as RAMP } from './parts'; export function MergeSizeCard({ buckets, diff --git a/frontend-v2/src/views/stats/PulseCard.tsx b/frontend-v2/src/views/stats/PulseCard.tsx new file mode 100644 index 00000000..49ea68ba --- /dev/null +++ b/frontend-v2/src/views/stats/PulseCard.tsx @@ -0,0 +1,28 @@ +import type { DayCount } from '../../model/stats'; +import { MiniColumns, StatsCard } from './parts'; + +/** + * Review pulse: CR+QA stamps landed per day. Re-stamps count — they're real + * review work — so this reads "how much reviewing happened", the input-side + * twin of the Shipping card's output. + */ +export function PulseCard({ perDay }: { perDay: DayCount[] }) { + const total = perDay.reduce((a, d) => a + d.count, 0); + const quietDays = perDay.filter(d => d.count === 0).length; + return ( + +
+ +
+
+ {total} stamps + {quietDays > 0 && ( + + {' '} + · {quietDays} quiet day{quietDays > 1 ? 's' : ''} + + )} +
+
+ ); +} diff --git a/frontend-v2/src/views/stats/ReciprocityCard.tsx b/frontend-v2/src/views/stats/ReciprocityCard.tsx new file mode 100644 index 00000000..871ed46d --- /dev/null +++ b/frontend-v2/src/views/stats/ReciprocityCard.tsx @@ -0,0 +1,48 @@ +import type { Reciprocity } from '../../model/stats'; +import { BarRow, PersonCell, StatsCard } from './parts'; + +/** + * Review give-and-take: PRs you stamped for others (the bar) against stamps + * others put on yours (the trailing number). A healthy board reads roughly + * balanced; all-bar people are carrying it, no-bar people are coasting. + */ +export function ReciprocityCard({ + rows, + me, + onPerson, + cap = 10, +}: { + rows: Reciprocity[]; + me?: string; + onPerson?: (login: string) => void; + cap?: number; +}) { + const shown = rows.slice(0, cap); + const max = Math.max(...rows.map(r => r.given), 1); + const more = rows.length - shown.length; + return ( + + {shown.length === 0 ? ( +
No review activity in this window.
+ ) : ( +
+ {shown.map(r => ( + } + trail={ + + {r.given} / {r.received} + + } + /> + ))} + {more > 0 &&
+{more} more people
} +
+ )} +
+ ); +} diff --git a/frontend-v2/src/views/stats/RepoLoadCard.tsx b/frontend-v2/src/views/stats/RepoLoadCard.tsx new file mode 100644 index 00000000..63567406 --- /dev/null +++ b/frontend-v2/src/views/stats/RepoLoadCard.tsx @@ -0,0 +1,42 @@ +import { shortRepo } from '../../format'; +import type { RepoLoad } from '../../model/stats'; +import { BarRow, StatsCard } from './parts'; + +/** + * Where the open PRs live. The bar is the repo's whole pile; the trailing + * numbers split out how much of it is still review work and how old the + * oldest is, so a big-but-moving repo reads differently from a stuck one. + */ +export function RepoLoadCard({ rows, cap = 10 }: { rows: RepoLoad[]; cap?: number }) { + const shown = rows.slice(0, cap); + const max = Math.max(...rows.map(r => r.count), 1); + const more = rows.length - shown.length; + return ( + +
+ {shown.map(r => ( + + {shortRepo(r.repo)} + + } + trail={ + + {r.count} + + · {r.awaitingCr} CR · {r.oldestDays}d + + + } + /> + ))} + {more > 0 &&
+{more} more repos
} +
+
+ ); +} diff --git a/frontend-v2/src/views/stats/ThroughputCard.tsx b/frontend-v2/src/views/stats/ThroughputCard.tsx new file mode 100644 index 00000000..ce6eee56 --- /dev/null +++ b/frontend-v2/src/views/stats/ThroughputCard.tsx @@ -0,0 +1,52 @@ +import { humanHours, type DayCount, type Latency } from '../../model/stats'; +import { MiniColumns, StatsCard } from './parts'; + +/** + * Shipping cadence over the closed window: merges per day, this week against + * last, and how fast a PR gets its first CR — the responsiveness number that + * time-to-merge (which includes the author's own iteration) hides. + */ +export function ThroughputCard({ perDay, firstCr }: { perDay: DayCount[]; firstCr: Latency }) { + const total = perDay.reduce((a, d) => a + d.count, 0); + const half = Math.floor(perDay.length / 2); + const lastWeek = perDay.slice(0, half).reduce((a, d) => a + d.count, 0); + const thisWeek = perDay.slice(half).reduce((a, d) => a + d.count, 0); + const delta = thisWeek - lastWeek; + + return ( + +
+ +
+
+ + {total} merged + + + this week {thisWeek} vs{' '} + {lastWeek} + {delta !== 0 && ( + 0 ? 'var(--ok)' : 'var(--warn)' }} + > + {delta > 0 ? '+' : ''} + {delta} + + )} + + {firstCr.sampled > 0 && ( + + first CR in{' '} + + {humanHours(firstCr.medianHours)} + {' '} + median + + )} +
+
+ ); +} diff --git a/frontend-v2/src/views/stats/parts.tsx b/frontend-v2/src/views/stats/parts.tsx index ae5b0ae3..b4228b1b 100644 --- a/frontend-v2/src/views/stats/parts.tsx +++ b/frontend-v2/src/views/stats/parts.tsx @@ -1,5 +1,16 @@ import type { ReactNode } from 'react'; import { Avatar } from '../../components/bits'; +import type { Weight } from '../../model/status'; +import type { DayCount } from '../../model/stats'; + +/** the light→heavy color read every weight surface shares */ +export const WEIGHT_RAMP: Record = { + XS: 'var(--ok)', + S: 'var(--ok)', + M: 'var(--ink-3)', + L: 'var(--warn)', + XL: 'var(--bad)', +}; /** * The shared chrome every Stats card wore by hand: a rounded surface panel @@ -58,6 +69,38 @@ export function BarRow({ ); } +/** + * A day-per-column mini chart: one rounded bar per day, height proportional + * to the column max, today rightmost. Each column carries its date + count as + * a tooltip; zero days keep a 2px stub so the timeline reads continuous. + */ +export function MiniColumns({ + days, + color, + unit, +}: { + days: DayCount[]; + color: string; + unit: string; +}) { + const max = Math.max(...days.map(d => d.count), 1); + return ( +
+ {days.map(d => ( +
+ ))} +
+ ); +} + /** * Avatar + login, the current user tinted brand and tagged "you" — the leading * cell the leaderboard and starvation cards render the same way. From eec37ebb521b441b76c41a83ea94259e30ec08c7 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 19 Jul 2026 11:10:56 -0700 Subject: [PATCH 114/381] Build both frontends from the one install: chain build:v2 in postinstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server already serves v1 and v2 from the same process — the only split was the build. Chaining build:v2 into postinstall makes the existing deploy (docker build → npm install --unsafe-perm) produce both frontends with no Dockerfile or run-command changes. This is the hard chain on purpose: a v2 build failure now fails the deploy instead of silently shipping a stale or missing /v2. The dependency trees stay deliberately separate (no workspaces): v1 is React 17 + webpack, v2 is React 19 + Vite, and hoisting into one install graph could change what v1's webpack resolves — the one thing this branch has provably never touched. build:v2 also switches from npm install to npm ci so image builds are reproducible off the committed lockfile. Verified: npm run build:v2 passes with npm ci (lockfile in sync, dist built); rollback is unchanged — master's postinstall has no chain, so redeploying master builds v1 only. Co-Authored-By: Claude Fable 5 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a7bd6230..e00577f0 100644 --- a/package.json +++ b/package.json @@ -65,13 +65,13 @@ "lint": "eslint ./", "frontend:start": "DUMMY_USER=danielbeardsley DUMMY_PULLS=./frontend/dummy-pulls.json webpack-dev-server --config ./frontend/webpack.dev.config.js", "frontend:watch": "webpack --watch --config ./frontend/webpack.dev.config.js", - "postinstall": "npm run build", + "postinstall": "npm run build && npm run build:v2", "build": "webpack --config ./frontend/webpack.prod.config.js", "test": "CONFIG_PATH=../test/fixtures/config.js node --test test/*.test.js", "precommit": "lint-staged", "frontend2:dev": "cd frontend-v2 && npm run dev", "frontend2:dummy": "cd frontend-v2 && npm run dev:dummy", - "build:v2": "cd frontend-v2 && npm install && npm run build" + "build:v2": "cd frontend-v2 && npm ci && npm run build" }, "lint-staged": { "*.(js|jsx|ts|tsx)": [ From bbd687cd60929ff76c04a4213299962ca00a7182 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 19 Jul 2026 11:13:52 -0700 Subject: [PATCH 115/381] Restructure the legend: grouped key, wrapping term column, real keycaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CSS bugs and one design debt. The term column was a fixed 74px with flex-none children and no wrap, so any wide sample — the two row-flag examples, the new+updated chip pair — overflowed straight into the definition text and overlapped it. The term cell now right-aligns and wraps within its own 88px column, so a wide sample folds to a second line instead of colliding. The redesign turns the flat 15-row glossary dump into a grouped key: Sign-offs, Reading a row, Badges, Catching up, and Keys, each under a small uppercase header with a hairline rule. The keyboard row becomes four per-key rows with real keycap chips instead of a bold "/ j k c" string; badges use their inline (text-sized) variant; the panel caps at 85vh and scrolls for short windows. Verified in the dummy board: 18 rows, zero term/definition overlaps (measured every row's boxes, including children escaping the term cell), all five groups and keycaps rendering. tsc and build pass. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Legend.tsx | 274 +++++++++++++++++--------- 1 file changed, 176 insertions(+), 98 deletions(-) diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 4e73bc4b..f3805286 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -1,28 +1,61 @@ +import type { ReactNode } from 'react'; import { useSettings } from '../settings'; import { FreshTag, WeightMeter } from './bits'; import { Popover } from './Popover'; +/** a keycap, sized to the legend's small type */ +function Kbd({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * One term/definition row. The term column is right-aligned and wraps within + * its own width — a wide sample (two chips, a long flag) folds to a second + * line inside the column instead of overflowing into the definition. + */ +function Item({ term, def }: { term: ReactNode; def: ReactNode }) { + return ( +
+ + {term} + + {def} +
+ ); +} + +/** a labeled band of the key; every band after the first draws a hairline */ +function Group({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
+ {title} +
+ {children} +
+ ); +} + /** * The one-stop decoder for the board's invented vocabulary. New hires can't * learn "stamp" or the slashed pip from hover titles alone — this is the - * visible answer the review pass found missing. + * visible answer the review pass found missing. Grouped so it reads as a + * key, not a glossary dump: sign-offs, then the row anatomy, then badges, + * then catching up, then keys. */ export function Legend() { const s = useSettings(); - const item = (term: React.ReactNode, def: string) => ( -
- {term} - {def} -
- ); - return ( ( @@ -161,7 +165,7 @@ export function Filters({ @@ -170,7 +174,7 @@ export function Filters({ type="button" onClick={() => setRepoPref(name, 'mute')} title={`mute ${shortRepo(name)} — hide it on your board`} - className="pressable rounded border-0 bg-transparent px-1 text-[11px] text-ink-3 hover:text-brand" + className="hit pressable rounded border-0 bg-transparent px-1 text-[11px] text-ink-3 hover:text-brand" > Mute @@ -197,7 +201,7 @@ export function Filters({ type="button" className={`pressable inline-flex h-8 max-w-[280px] items-center gap-1.5 rounded-lg border px-2.5 text-[13px] font-medium ${ active - ? 'border-brand bg-brand-50 text-brand-700' + ? 'border-brand bg-brand-50 text-brand-700 hover:border-brand-700' : 'border-line bg-surface text-ink-2 hover:text-brand' }`} title={summary} @@ -243,7 +247,7 @@ export function Filters({ e.preventDefault(); setShowAll(!showAll); }} - className="ml-auto font-medium text-brand hover:underline" + className="hit ml-auto font-medium text-brand hover:underline" > {showAll ? 'stop showing all' : 'show all'} @@ -341,7 +345,7 @@ export function Filters({ diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index f3805286..3e4a31b9 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -119,7 +119,7 @@ export function Legend() { {s.ageRotDays}d } - def={`age: hours under a day, amber past ${s.ageWarnDays} days, red past ${s.ageRotDays}. Hover for both clocks`} + def={`age: hours under a day, amber past ${s.ageWarnDays} days, red past ${s.ageRotDays}. Hover or tap it for both clocks`} /> } diff --git a/frontend-v2/src/components/RepoManager.tsx b/frontend-v2/src/components/RepoManager.tsx index 5b4dcecc..4f8be4db 100644 --- a/frontend-v2/src/components/RepoManager.tsx +++ b/frontend-v2/src/components/RepoManager.tsx @@ -61,7 +61,9 @@ export function RepoManagerGroup({
{muted.map(repo => (
- {repo.name} + + {repo.name} + {repo.count} onRepoPref(repo.name, null)}>Unmute
@@ -76,7 +78,9 @@ export function RepoManagerGroup({
{orgHiddenRepos.map(repo => (
- {repo.name} + + {repo.name} + {repo.count} onRepoPref(repo.name, 'show')}> Show for me @@ -102,8 +106,10 @@ export function RepoManagerGroup({ return (
- {repo.name} + + {repo.name} + {isUserRevealed && ( org-hidden · showing )} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index d165ded8..f33a4d43 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -155,7 +155,7 @@ function RowDetails({ flags }: { flags: Flag[] }) { {...t} type="button" aria-label={`row details: ${flags.map(f => f.label).join(', ')}`} - className="pd-raise pressable -my-2 inline-flex cursor-default flex-wrap items-center gap-x-2 gap-y-1 rounded px-0.5 py-2 hover:bg-secondary/60" + className="pd-raise pressable -my-2 inline-flex flex-wrap items-center gap-x-2 gap-y-1 rounded px-0.5 py-2 hover:bg-secondary/60" > {flags.map(f => ( { void navigator.clipboard.writeText(pull.data.head.ref); setCopied(true); @@ -227,7 +227,7 @@ function RowActions({ pull, overlay }: { pull: DerivedPull; overlay?: boolean }) type="button" aria-label="snooze: hide until tomorrow or until it changes" title="snooze: hide until tomorrow or until it changes" - className="pressable rounded border-0 bg-transparent px-1 text-xs text-ink-3 hover:text-brand" + className="hit pressable rounded border-0 bg-transparent px-1 text-xs text-ink-3 hover:text-brand" onClick={() => snoozePull(pullKey(pull.data))} > @@ -238,7 +238,7 @@ function RowActions({ pull, overlay }: { pull: DerivedPull; overlay?: boolean }) type="button" aria-label="re-fetch this PR from GitHub" title="re-fetch this PR from GitHub" - className="pressable rounded border-0 bg-transparent px-1 text-xs text-ink-3 hover:text-brand" + className="hit pressable rounded border-0 bg-transparent px-1 text-xs text-ink-3 hover:text-brand" onClick={() => { refreshPull(pull.data.repo, pull.data.number); setSpinning(true); diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index d5c8c88f..ac8bea9b 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -446,7 +446,7 @@ export function Settings({ }} className={`pressable inline-flex h-8 items-center rounded-lg border px-3 text-[13px] font-medium ${ armReset - ? 'border-bad bg-bad/10 text-bad' + ? 'border-bad bg-bad/10 text-bad hover:bg-bad/20' : 'border-line bg-surface text-ink-2 hover:text-bad' }`} > diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 9d4964ee..77e1409a 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -73,7 +73,7 @@ export function QuietButton({ return ( ); @@ -145,7 +145,8 @@ export function Avatar({ return onClick ? ( {label} stamps + {/* restate the fraction: the trigger's hover title is mouse-only, + and a tap-opened panel must carry the "how many still needed" */} + + {label} stamps + {req > 0 && ( + + · {have} of {req} + + )} + {rows.map(s => ( ( + + )} > - {text} - + + opened {ago(createdAt)} ago + + + last activity {ago(updatedAt)} ago + + ); } diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 35eb8b2a..ff26cad7 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -106,8 +106,32 @@ body { font: 400 14px/1.5 ui-sans-serif, system-ui, sans-serif; } -a:hover { - color: var(--brand); +/* gate hand-authored hovers the way Tailwind v4 gates its hover: variants — + touch taps must not leave a sticky highlight */ +@media (hover: hover) { + a:hover { + color: var(--brand); + } +} + +/* Tailwind v4 ships no button cursor at all (native arrow); the app's + convention is the hand on everything clickable, declared once instead of + remembered 27 times. Disabled buttons keep the arrow. */ +button:not(:disabled), +summary, +label:has(> input:not(:disabled)) { + cursor: pointer; +} + +/* extend a small control's tap target past 24px without moving any layout — + the pseudo-element takes the hits, the visual box stays put */ +.hit { + position: relative; +} +.hit::after { + content: ""; + position: absolute; + inset: -5px; } /* shared focus ring (house: border-ring + ring/50 at 3px) */ @@ -206,7 +230,11 @@ a:hover { :focus-within matters: a visibility:hidden element can never take focus, so the buttons must become visible when ANY part of the row is focused (tabbing to the title), or the keyboard could never reach them. */ -.pd-row:hover .row-actions, +@media (hover: hover) { + .pd-row:hover .row-actions { + visibility: visible; + } +} .pd-row:focus-within .row-actions { visibility: visible; } diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx index df456cbc..e190820f 100644 --- a/frontend-v2/src/views/People.tsx +++ b/frontend-v2/src/views/People.tsx @@ -91,7 +91,9 @@ export function People({ onClick={onPick} aria-pressed={active} className={`pressable inline-flex items-center gap-1.5 rounded-lg border bg-surface py-[5px] pr-2.5 pl-1.5 text-[13px] font-medium text-ink-2 ${ - active ? 'border-brand shadow-[0_0_0_1px_var(--brand)]' : 'border-line hover:bg-muted' + active + ? 'border-brand shadow-[0_0_0_1px_var(--brand)] hover:bg-muted' + : 'border-line hover:bg-muted' }`} > {label} From a0cc8ce28ca4f917ffe4a27a5c73be49f96506dd Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 19 Jul 2026 12:21:58 -0700 Subject: [PATCH 120/381] Give phones the row actions via a kebab menu; clamp popovers on-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Below 720px the copy/snooze/refresh cluster was display:none with no replacement — hover-reveal has no phone story, so the actions simply didn't exist on touch devices (flagged by the pointer-modality audit). Every row's rail now carries a quiet kebab below 720px that opens a tap-friendly labeled menu (Copy branch name with the branch shown, Snooze until tomorrow, Re-fetch from GitHub) through the house click-to-open popover — no hover anywhere in the path. The three actions extract into a shared useRowActions hook and one icon set, so the desktop cluster and the phone menu can never disagree on behavior; the desktop cluster is unchanged and the kebab is hidden at >=720px. Building it exposed a general Popover bug: positioning anchored to the trigger with no viewport clamp, so a right-anchored panel wider than the space left of its trigger hung off a phone screen. place() now measures the panel (second pass after first paint) and clamps to 8px gutters — every popover benefits, not just the menu. Verified at 375px in the dummy board: the kebab is on every row, the menu opens fully on-screen (16..288 of 375), Copy shows its feedback, and Snooze genuinely removes the row; the age popover clamps too. At desktop width the kebab is hidden and the hover cluster is back. tsc, build, and all 64 tests pass. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Popover.tsx | 13 ++- frontend-v2/src/components/Row.tsx | 147 +++++++++++++++++++------ 2 files changed, 128 insertions(+), 32 deletions(-) diff --git a/frontend-v2/src/components/Popover.tsx b/frontend-v2/src/components/Popover.tsx index 1a43579d..97ac12b2 100644 --- a/frontend-v2/src/components/Popover.tsx +++ b/frontend-v2/src/components/Popover.tsx @@ -59,15 +59,26 @@ export function Popover({ if (!el) return; const r = el.getBoundingClientRect(); const top = r.bottom + 4; + // clamp into the viewport (8px gutters): a right-anchored panel wider + // than the space left of its trigger would otherwise hang off-screen + // — the phone kebab menu found this the hard way + const panelW = pop.panelRef.current?.offsetWidth ?? 0; + const max = window.innerWidth - panelW - 8; setPos( - side === 'right' ? { top, right: window.innerWidth - r.right } : { top, left: r.left } + side === 'right' + ? { top, right: Math.max(Math.min(window.innerWidth - r.right, max), 8) } + : { top, left: Math.max(Math.min(r.left, max), 8) } ); }; place(); + // second pass once the panel exists: the first ran before it rendered, + // so the clamp had no width to measure + const t = setTimeout(place, 0); // capture-phase catches scrolls on any ancestor, not just window window.addEventListener('scroll', place, true); window.addEventListener('resize', place); return () => { + clearTimeout(t); window.removeEventListener('scroll', place, true); window.removeEventListener('resize', place); }; diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index f33a4d43..f36c5808 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -184,16 +184,63 @@ function RowDetails({ flags }: { flags: Flag[] }) { ); } +const ICON_COPY = + 'M5 1a1 1 0 0 0-1 1v1H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1a1 1 0 0 0 1-1V4.4L11.6 1H5Zm6 11v1H3V4h1v7a1 1 0 0 0 1 1h6Zm2-2H5V2h5v3h3v5Z'; +const ICON_SNOOZE = + 'M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 1.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Zm-.75 2v4.06l3.1 1.86.77-1.28-2.37-1.42V4.5h-1.5Z'; +const ICON_REFRESH = + 'M8 3a5 5 0 1 0 4.9 6h-1.55A3.5 3.5 0 1 1 8 4.5c.97 0 1.85.4 2.48 1.02L8.5 7.5H13V3l-1.46 1.46A4.98 4.98 0 0 0 8 3Z'; +const ICON_KEBAB = + 'M8 4.4a1.4 1.4 0 1 1 0-2.8 1.4 1.4 0 0 1 0 2.8Zm0 5a1.4 1.4 0 1 1 0-2.8 1.4 1.4 0 0 1 0 2.8Zm0 5a1.4 1.4 0 1 1 0-2.8 1.4 1.4 0 0 1 0 2.8Z'; + +function ActionIcon({ d, spin }: { d: string; spin?: boolean }) { + return ( + + + + ); +} + +/** + * The three per-row actions, shared by the desktop hover cluster and the + * mobile kebab menu so the two surfaces can never disagree on behavior. + */ +function useRowActions(pull: DerivedPull) { + const [copied, setCopied] = useState(false); + const [spinning, setSpinning] = useState(false); + return { + copied, + spinning, + copy: () => { + void navigator.clipboard.writeText(pull.data.head.ref); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + }, + snooze: () => snoozePull(pullKey(pull.data)), + refresh: () => { + refreshPull(pull.data.repo, pull.data.number); + setSpinning(true); + setTimeout(() => setSpinning(false), 600); + }, + }; +} + /** * Hover/focus actions: copy the branch name, snooze, re-fetch from GitHub. * `overlay` (compact) floats them over the row's tail instead of reserving * rail width — .row-actions hides them with visibility, which still takes * layout space, and in a narrow column that standing ~70px tax comes straight - * out of the title. + * out of the title. Hidden below 720px, where RowActionsKebab takes over + * (hover-reveal has no phone story). */ function RowActions({ pull, overlay }: { pull: DerivedPull; overlay?: boolean }) { - const [copied, setCopied] = useState(false); - const [spinning, setSpinning] = useState(false); + const a = useRowActions(pull); + const btn = + 'hit pressable rounded border-0 bg-transparent px-1 text-xs text-ink-3 hover:text-brand'; return ( ); } +/** + * The same three actions for pointers that can't hover: below 720px the + * desktop cluster simply doesn't exist, so every row keeps a quiet, + * always-visible kebab that opens a tap-friendly labeled menu (the house + * click-to-open popover — no hover in the path). + */ +function RowActionsKebab({ pull }: { pull: DerivedPull }) { + const a = useRowActions(pull); + const item = + 'flex w-full items-center gap-2 rounded-md border-0 bg-transparent px-2 py-2 text-left text-xs text-ink-2 hover:bg-muted'; + return ( + ( + + )} + > + + + + + ); +} + /** * The metric rail every row ends on, in one order everywhere: how heavy to * review, then the CR and QA sign-off pips, then age. Right-anchored and @@ -273,6 +357,7 @@ function MetricRail({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { }`} > + Date: Sun, 19 Jul 2026 16:28:04 -0700 Subject: [PATCH 121/381] Add the myTeam setting and team bucketing model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Team lens needs two model-layer pieces before any UI exists: a per-browser list of teammate logins (deliberately NOT GitHub teams — the people you actually work with rarely match the org chart, and a setting needs no server or DB change), and the rules for how a team-authored pull sorts into review buckets. teamBuckets mirrors People.tsx's per-person reviewable/mine/rest split generalized to a member list, so the two views can never drift on what "reviewable by you" means. A needs_recr pull naming you in recrBy lands in stamped, not reviewable: your stale stamp is a personal to-do that Review's "Yours to do" already surfaces. isBotLogin moves from an app.tsx local into model/visibility.ts so the upcoming team picker can exclude bots without duplicating the check or importing from app.tsx (which would cycle). Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/team.test.ts | 85 +++++++++++++++++++++++++++++ frontend-v2/src/model/team.ts | 47 ++++++++++++++++ frontend-v2/src/model/visibility.ts | 7 +++ frontend-v2/src/settings.ts | 12 ++++ 4 files changed, 151 insertions(+) create mode 100644 frontend-v2/src/model/team.test.ts create mode 100644 frontend-v2/src/model/team.ts diff --git a/frontend-v2/src/model/team.test.ts b/frontend-v2/src/model/team.test.ts new file mode 100644 index 00000000..e00b36c2 --- /dev/null +++ b/frontend-v2/src/model/team.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import type { DerivedPull, Status, Weight } from './status'; +import { teamBuckets } from './team'; + +/** A DerivedPull with only the fields teamBuckets and crSort read. */ +function dp(o: { + author?: string; + status: Status; + crBy?: string[]; + recrBy?: string[]; + ageDays?: number; + weight?: Weight; + sizeKnown?: boolean; + crHave?: number; +}): DerivedPull { + return { + data: { + user: { login: o.author ?? 'author' }, + status: { cr_req: 1, qa_req: 1 }, + additions: 10, + deletions: 0, + updated_at: '2024-01-01T00:00:00Z', + }, + status: o.status, + crBy: o.crBy ?? [], + recrBy: o.recrBy ?? [], + ageDays: o.ageDays ?? 0, + weight: o.weight ?? 'M', + sizeKnown: o.sizeKnown ?? true, + crHave: o.crHave ?? 0, + } as unknown as DerivedPull; +} + +describe('teamBuckets — what a team-authored pull sorts into', () => { + it('includes a pull whose author is on the team', () => { + const p = dp({ author: 'alice', status: 'needs_cr' }); + const { reviewable } = teamBuckets([p], ['alice'], 'me'); + expect(reviewable).toEqual([p]); + }); + + it('excludes a pull authored by me, even if I ended up in the member list', () => { + const p = dp({ author: 'me', status: 'needs_cr' }); + const buckets = teamBuckets([p], ['me'], 'me'); + expect(buckets).toEqual({ reviewable: [], stamped: [], rest: [] }); + }); + + it('moves a pull to stamped once I hold a CR stamp on it', () => { + const p = dp({ author: 'alice', status: 'needs_cr', crBy: ['me'] }); + const { reviewable, stamped } = teamBuckets([p], ['alice'], 'me'); + expect(reviewable).toEqual([]); + expect(stamped).toEqual([p]); + }); + + it('moves a needs_recr pull to stamped when the stale stamp owed is mine', () => { + // recrBy naming you means YOUR earlier stamp went stale — that's a + // personal to-do (Review's "Yours to do"), not a fresh pick for the + // review queue, so it lands in stamped, same as People.tsx's mine. + const p = dp({ author: 'alice', status: 'needs_recr', recrBy: ['me'] }); + const { reviewable, stamped } = teamBuckets([p], ['alice'], 'me'); + expect(reviewable).toEqual([]); + expect(stamped).toEqual([p]); + }); + + it('lands non-CR statuses in rest', () => { + const p = dp({ author: 'alice', status: 'ready' }); + const { reviewable, stamped, rest } = teamBuckets([p], ['alice'], 'me'); + expect(reviewable).toEqual([]); + expect(stamped).toEqual([]); + expect(rest).toEqual([p]); + }); + + it('sorts reviewable with crSort, not input order', () => { + const heavy = dp({ author: 'alice', status: 'needs_cr', weight: 'XL' }); + const light = dp({ author: 'alice', status: 'needs_cr', weight: 'XS' }); + // heavy passed first — crSort should still put the lighter pull first + const { reviewable } = teamBuckets([heavy, light], ['alice'], 'me'); + expect(reviewable).toEqual([light, heavy]); + }); + + it('returns everything empty when the team has no members', () => { + const p = dp({ author: 'alice', status: 'needs_cr' }); + const buckets = teamBuckets([p], [], 'me'); + expect(buckets).toEqual({ reviewable: [], stamped: [], rest: [] }); + }); +}); diff --git a/frontend-v2/src/model/team.ts b/frontend-v2/src/model/team.ts new file mode 100644 index 00000000..3fe70fd7 --- /dev/null +++ b/frontend-v2/src/model/team.ts @@ -0,0 +1,47 @@ +import { STATUS_ORDER, type DerivedPull } from './status'; +import { crSort } from './sort'; + +export interface TeamBuckets { + reviewable: DerivedPull[]; + stamped: DerivedPull[]; + rest: DerivedPull[]; +} + +/** + * The Team view's three buckets, pulled out of the view so the rules are + * unit-testable without mounting React. Mirrors People.tsx's per-person split + * (reviewable / mine / rest) generalized to a set of members: a needs_recr + * pull naming you in `recrBy` means YOUR stamp went stale, which is a + * personal to-do surfaced elsewhere (Review's "Yours to do"), not an open + * item for the review queue — so it lands in `stamped`, same as People.tsx. + * The author being you is excluded everywhere, even if `me` ends up in + * `members` by mistake — you can't review your own PR. + */ +export function teamBuckets( + pulls: DerivedPull[], + members: string[], + me: string | null +): TeamBuckets { + const memberSet = new Set(members); + const theirs = pulls.filter(p => memberSet.has(p.data.user.login) && p.data.user.login !== me); + + const reviewable = crSort( + theirs.filter( + p => + ['needs_cr', 'needs_recr'].includes(p.status) && + !p.crBy.includes(me ?? '') && + !p.recrBy.includes(me ?? '') + ) + ); + const stamped = theirs.filter( + p => + ['needs_cr', 'needs_recr'].includes(p.status) && + (p.crBy.includes(me ?? '') || p.recrBy.includes(me ?? '')) + ); + const inLane = new Set([...reviewable, ...stamped]); + const rest = theirs + .filter(p => !inLane.has(p)) + .sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status)); + + return { reviewable, stamped, rest }; +} diff --git a/frontend-v2/src/model/visibility.ts b/frontend-v2/src/model/visibility.ts index dff42c89..3b09006c 100644 --- a/frontend-v2/src/model/visibility.ts +++ b/frontend-v2/src/model/visibility.ts @@ -37,3 +37,10 @@ export function repoHidden( ): boolean { return repoState(repo, orgHidden, prefs) !== 'shown'; } + +/** GitHub Apps carry a [bot] suffix; other machine accounts are named in + * config.json's `bots` list. Shared by the board's bot fold and anywhere + * else a login list must leave bots out (e.g. the team picker). */ +export function isBotLogin(login: string, extra: ReadonlySet): boolean { + return login.endsWith('[bot]') || extra.has(login); +} diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index bfad5d76..a33787b6 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -43,6 +43,9 @@ export interface Settings { * firmware dev share a monorepo but little else). Empty = infer from the * repos where you've authored or stamped on the current board. */ primaryRepos: string[]; + /** logins of your teammates (you are implicit; not GitHub teams — a + * per-browser list powering the Team lens and the "Your team" filter). */ + myTeam: string[]; } export const DEFAULT_SETTINGS: Settings = { @@ -60,6 +63,7 @@ export const DEFAULT_SETTINGS: Settings = { laneCap: 10, selfReview: true, primaryRepos: [], + myTeam: [], }; const store = createPersistentStore('pd2.settings', DEFAULT_SETTINGS); @@ -88,6 +92,14 @@ export function togglePrimaryRepo(repo: string, primary: boolean) { setSettings({ primaryRepos: next }); } +/** Add or remove a teammate. Deduped and sorted so the Team view and picker + * render in a stable order regardless of insertion order. */ +export function toggleTeammate(login: string, add: boolean) { + const cur = store.get().myTeam; + const next = add ? [...new Set([...cur, login])].sort() : cur.filter(l => l !== login); + setSettings({ myTeam: next }); +} + export function useSettings(): Settings { return store.useValue(); } From 60c3b712d091770f3217bf3d9fac4dbdd7c924d6 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 19 Jul 2026 16:28:16 -0700 Subject: [PATCH 122/381] Add the team picker to Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker seeds its suggestions from everyone already on the board (authors plus CR/QA signers, ranked by open-PR count) because that list is available for free and nearly always contains your team; a free-text "Add" row covers the teammate with nothing open right now. Bots and yourself are excluded — a dependency bot never reviews anything, and you are implicitly on your own team. TeamPicker is standalone (not fused into its Settings Group wrapper) so the Team view's empty state can embed the same picker inline instead of bouncing first-time users out to the cog menu. Settings gains an extraBots prop because config.json's named bots otherwise live only in app.tsx state. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Settings.tsx | 7 + frontend-v2/src/components/TeamPicker.tsx | 153 ++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 frontend-v2/src/components/TeamPicker.tsx diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index ac8bea9b..9b0ecd57 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -18,10 +18,12 @@ import { } from '../settings'; import { QuietButton, Segmented } from './bits'; import { RepoManagerGroup } from './RepoManager'; +import { TeamPickerGroup } from './TeamPicker'; const LENS_OPTIONS: [string, string][] = [ ['review', 'Review'], ['mine', 'My work'], + ['team', 'Team'], ['people', 'People'], ['classic', 'Classic'], ['stats', 'Stats'], @@ -88,12 +90,15 @@ export function Settings({ repos, orgHidden, snoozedCount, + extraBots, }: { /** every known repo with its open-PR count, for the repo manager */ repos: { name: string; count: number }[]; orgHidden: ReadonlySet; /** pulls currently hidden by a row snooze */ snoozedCount: number; + /** config.json's named bots, so the team picker's suggestions leave them out */ + extraBots: ReadonlySet; }) { const [open, setOpen] = useState(false); const s = useSettings(); @@ -321,6 +326,8 @@ export function Settings({ + + {notificationsSupported ? ( <> diff --git a/frontend-v2/src/components/TeamPicker.tsx b/frontend-v2/src/components/TeamPicker.tsx new file mode 100644 index 00000000..0bce5262 --- /dev/null +++ b/frontend-v2/src/components/TeamPicker.tsx @@ -0,0 +1,153 @@ +import { useMemo, useState } from 'react'; +import { isBotLogin } from '../model/visibility'; +import { toggleTeammate, useSettings } from '../settings'; +import { usePulldasher } from '../store'; +import { Avatar } from './bits'; + +const SUGGESTION_CAP = 12; +const EMPTY_BOTS: ReadonlySet = new Set(); + +/** One row: checkbox + avatar + login (+ optional trailing count), the same + * shape the Filters people tab uses so a teammate row reads identically + * wherever it appears. */ +function CandidateRow({ + login, + count, + checked, + onToggle, +}: { + login: string; + count?: number; + checked: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +/** Every author and signer on the board — the login universe a teammate can + * be picked from — with an open-PR count for sorting. Bots and yourself are + * excluded: you're implicitly on your own team, and a dependency bot never + * reviews anything. */ +function useCandidates(extraBots: ReadonlySet) { + const { pulls, me } = usePulldasher(); + return useMemo(() => { + const counts = new Map(); + const known = new Set(); + for (const p of pulls) { + const login = p.data.user.login; + known.add(login); + counts.set(login, (counts.get(login) ?? 0) + 1); + for (const sig of [...p.data.status.allCR, ...p.data.status.allQA]) { + known.add(sig.data.user.login); + } + } + return [...known] + .filter(login => login !== me && !isBotLogin(login, extraBots)) + .sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0) || a.localeCompare(b)) + .map(login => ({ login, count: counts.get(login) ?? 0 })); + }, [pulls, me, extraBots]); +} + +/** + * The team picker: current members first (uncheck to remove), then a + * searchable, capped list of everyone else on the board. Typing a login with + * no match on the board still lets you add it — a teammate can have nothing + * open right now. Standalone so the Team view's empty state can embed it + * without the Settings drawer's Group chrome (see TeamPickerGroup below). + */ +export function TeamPicker({ extraBots = EMPTY_BOTS }: { extraBots?: ReadonlySet }) { + const { myTeam } = useSettings(); + const candidates = useCandidates(extraBots); + const [query, setQuery] = useState(''); + + const members = new Set(myTeam); + const trimmed = query.trim(); + const needle = trimmed.toLowerCase(); + const available = candidates.filter(c => !members.has(c.login)); + const filtered = needle + ? available.filter(c => c.login.toLowerCase().includes(needle)) + : available.slice(0, SUGGESTION_CAP); + + const exactMatch = + !!trimmed && (members.has(trimmed) || candidates.some(c => c.login.toLowerCase() === needle)); + + return ( +
+ {myTeam.map(login => ( + toggleTeammate(login, false)} + /> + ))} + + setQuery(e.target.value)} + placeholder="Search or add a login" + type="text" + value={query} + /> + + {filtered.map(c => ( + toggleTeammate(c.login, true)} + /> + ))} + {filtered.length === 0 && !trimmed && ( +
No one else on the board yet.
+ )} + + {trimmed && !exactMatch && ( + + )} +
+ ); +} + +/** The Settings-drawer wrapper: same Group chrome RepoManagerGroup uses. */ +export function TeamPickerGroup({ extraBots }: { extraBots?: ReadonlySet }) { + return ( +
+

+ Your team +

+
+ Teammates power the Team view and the “Your team” filter. +
+ +
+ ); +} From b9e4f27d9688cc18259e68055e501611be8843fe Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 19 Jul 2026 16:28:26 -0700 Subject: [PATCH 123/381] Add the Team lens and merge your team into the filter chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The team lens answers two questions the Review lens can't scope to: what has my team written that I should look at, and where does each member stand. A member strip shows authored counts and owed re-stamps per person (clicking through to their People page), then the board narrows to just team-authored pulls: a crSort'd "review your team's work" lane, a fold for pulls you've already stamped, and the rest by status. An empty team onboards straight into the picker rather than pointing at Settings. Your picked team also injects as a "Your team" entry ahead of config.json's org teams, so the existing Filters people-tab chip and People-view chip pick it up through machinery that already exists — the team filter costs no new filter code and stays hash-shareable (team=Your+team, authors=...). Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 47 +++++++--- frontend-v2/src/views/Team.tsx | 151 +++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 frontend-v2/src/views/Team.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 6150dd31..26815f3f 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -9,27 +9,28 @@ import { } from 'react'; import { ago, epoch, n, shortRepo } from './format'; import type { DerivedPull } from './model/status'; -import type { Team } from './types'; +import type { Team as TeamGroup } from './types'; import { isSnoozed, setWeightLabels, usePulldasher } from './store'; import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy'; import { loadSiteConfig, primeScope, useScope } from './prefs'; import { getSettings, useSettings } from './settings'; import { useNotifications } from './notifications'; import { matchesQuery } from './model/query'; -import { CRYO_KEY, repoHidden } from './model/visibility'; +import { CRYO_KEY, isBotLogin, repoHidden } from './model/visibility'; import { Legend } from './components/Legend'; import { Filters } from './components/Filters'; import type { RowOptions } from './components/Row'; import { Review } from './views/Review'; import { MyWork } from './views/MyWork'; import { People } from './views/People'; +import { Team } from './views/Team'; import { Classic } from './views/Classic'; import { Stats } from './views/Stats'; import { Settings } from './components/Settings'; -type Lens = 'review' | 'mine' | 'people' | 'classic' | 'stats'; +type Lens = 'review' | 'mine' | 'team' | 'people' | 'classic' | 'stats'; -const LENSES: Lens[] = ['review', 'mine', 'people', 'classic', 'stats']; +const LENSES: Lens[] = ['review', 'mine', 'team', 'people', 'classic', 'stats']; /** * The whole view lives in the hash — lens, drill-downs, query, scope, @@ -95,11 +96,6 @@ if (urlState.repos.length || urlState.authors.length) { primeScope({ repos: urlState.repos, authors: urlState.authors }); } -// GitHub Apps carry a [bot] suffix; other machine accounts are named in -// config.json's `bots` list. -const isBotLogin = (login: string, extra: ReadonlySet) => - login.endsWith('[bot]') || extra.has(login); - /** Full-width notice under the header: bad = red alert, warn = amber, brand = informational. */ function Banner({ tone, children }: { tone: 'bad' | 'warn' | 'brand'; children: ReactNode }) { const inner: Record = { @@ -192,7 +188,7 @@ export function App() { const [draftsMode, setDraftsMode] = useState<'mine' | 'all'>( () => urlState.drafts ?? getSettings().draftsMode ); - const [teams, setTeams] = useState([]); + const [teams, setTeams] = useState([]); const [extraBots, setExtraBots] = useState>(new Set()); const isBot = useCallback( (p: DerivedPull) => isBotLogin(p.data.user.login, extraBots), @@ -201,6 +197,15 @@ export function App() { // theme, density, default view, age colors, glance guard — all live in // settings now (the cog panel), persisted per-browser const settings = useSettings(); + // your personal team merges into the org's config.json teams, leading the + // list — it's the one a viewer actually picked, not a standing org fixture + const allTeams = useMemo( + () => + settings.myTeam.length + ? [{ team: 'Your team', members: settings.myTeam }, ...teams] + : teams, + [teams, settings.myTeam] + ); const [systemDark, setSystemDark] = useState( () => matchMedia('(prefers-color-scheme: dark)').matches ); @@ -520,12 +525,18 @@ export function App() { v1 board
- +
- + + {legacy && (
- + + {avatarBadge} {titleLink} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 1f682cde..104a884b 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -2,8 +2,15 @@ import { memo, useState } from 'react'; import type { DerivedPull } from '../model/status'; import { isIterating, lastPushEpoch } from '../model/status'; import { rowNote } from '../model/actions'; -import { ago, epoch, pullKey } from '../format'; -import { ackPull, isFresh, refreshPull, snoozePull } from '../store'; +import { ago, epoch, pullKey, shortRepo } from '../format'; +import { + setRepoPref, + toggleMutedPerson, + togglePrimaryRepo, + toggleStarredPerson, + useSettings, +} from '../settings'; +import { ackPull, isFresh, refreshPull, snoozePull, usePulldasher } from '../store'; import { AgeStamp, DiffSize, FreshTag, RepoRef, SigPips, StatusBadge, WeightMeter } from './bits'; import { CardShell } from './Card'; import { FeedbackPopover } from './FeedbackPopover'; @@ -288,20 +295,44 @@ function RowActions({ pull, overlay }: { pull: DerivedPull; overlay?: boolean }) } /** - * The same three actions for pointers that can't hover: below 720px the - * desktop cluster simply doesn't exist, so every row keeps a quiet, - * always-visible kebab that opens a tap-friendly labeled menu (the house - * click-to-open popover — no hover in the path). + * The star/mute glyph every kebab star item shares: a fixed-width slot the + * same size as the SVG action icons, so the menu's leading column still + * lines up even though these two rows use a text glyph instead of a path. + */ +function StarGlyph({ on }: { on: boolean }) { + return ( + + {on ? '★' : '☆'} + + ); +} + +/** + * The same three actions for pointers that can't hover, plus the star/mute + * board actions that only live here (never on the desktop icon cluster — + * see RowActions). Below 720px the desktop cluster doesn't exist at all, so + * every row keeps a quiet, always-visible kebab that opens a tap-friendly + * labeled menu (the house click-to-open popover — no hover in the path); at + * and above 720px it now rides at the end of the hover cluster too, since + * star/mute have no other home there. */ function RowActionsKebab({ pull }: { pull: DerivedPull }) { const a = useRowActions(pull); + const settings = useSettings(); + const { me } = usePulldasher(); + const repo = pull.data.repo; + const author = pull.data.user.login; + const repoLabel = shortRepo(repo); + const isPrimaryRepo = settings.primaryRepos.includes(repo); + const isMutedRepo = settings.repoPrefs[repo] === 'mute'; + const isStarredAuthor = settings.starredPeople.includes(author); const item = 'flex w-full items-center gap-2 rounded-md border-0 bg-transparent px-2 py-2 text-left text-xs text-ink-2 hover:bg-muted'; return ( Re-fetch from GitHub +
+ + + + {/* never offered for your own pulls — you can't mute yourself off + your own board */} + {author !== me && ( + + )} ); } @@ -399,6 +484,10 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { const note = rowNote(pull, opts.me); // "iterating" and a "fix pushed …" note say the same thing — don't say it twice const showIterating = isIterating(pull) && !(note.context ?? '').includes('pushed'); + // the smallest clean marker for a starred author: a tiny ★ over their + // avatar, so the row itself says "you follow this person" without a + // trip to the kebab menu + const starredAuthor = useSettings().starredPeople.includes(d.user.login); return ( ackPull(key)} compact={opts.compact} className={`${flashOnce(key, !!fresh) ? 'row-fresh' : ''} transition-[background-color] duration-150 ease-out motion-reduce:transition-none`} + avatarBadge={ + starredAuthor && ( + + ★ + + ) + } meta={ // the same untruncated meta in both densities — the shell wraps // instead of clipping, so no chip ever costs the title its text From 71958f9d8c80c1ff7d083d3c3e25b3621180f36b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 19 Jul 2026 19:13:04 -0700 Subject: [PATCH 135/381] Add weight and viewer-state query tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewers triage by budget — "I have twenty minutes, show me the small stuff" — and by obligation — "what's actually my move here." The query grammar now answers both: weight:xs,s (comma list, OR within the token), has:action (this card carries an imperative for you, powered by rowNote), is:restamp (you owe a re-look), and is:blocked. Tokens compose with the existing grammar's AND semantics, and unknown has:/is: values match nothing, same as an unrecognized status:. The row's weight meter becomes a filter toggle: click to scope the board to that size class, click again to clear — the same in-context filtering avatars already do for people. The Legend gains a Query group documenting the grammar, which was previously undocumented there. matchesQuery now takes the viewer login, since has:action is inherently viewer-relative. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 14 +++- frontend-v2/src/components/Legend.tsx | 19 +++++ frontend-v2/src/components/Row.tsx | 35 +++++++- frontend-v2/src/model/query.test.ts | 112 ++++++++++++++++++++++---- frontend-v2/src/model/query.ts | 22 ++++- 5 files changed, 177 insertions(+), 25 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 3c48260a..5df24a4b 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -416,7 +416,7 @@ export function App() { // The legacy path owns its own draft rule, so don't double-apply. if (!legacy && draftsMode === 'mine') out = out.filter(p => !p.data.draft || p.data.user.login === me); - if (query) out = out.filter(p => matchesQuery(p, query)); + if (query) out = out.filter(p => matchesQuery(p, query, me)); return out; }, [ pulls, @@ -500,6 +500,12 @@ export function App() { setTeam(null); setLens('people'); }, []); + // a row's weight chip sets the query to that token; clicking the same + // chip again (query already exactly that token) clears it instead of + // re-applying it, so the chip doubles as its own toggle + const onQueryToken = useCallback((token: string) => { + setQuery(prev => (prev === token ? '' : token)); + }, []); // stable identity so memo(Row) can skip untouched rows on socket bursts const rowOpts: RowOptions = useMemo( () => ({ @@ -507,6 +513,7 @@ export function App() { lastSeen, acked, onPerson, + onQueryToken, ageWarnDays: settings.ageWarnDays, ageRotDays: settings.ageRotDays, compact: settings.density === 'compact', @@ -517,6 +524,7 @@ export function App() { lastSeen, acked, onPerson, + onQueryToken, settings.ageWarnDays, settings.ageRotDays, settings.density, @@ -668,9 +676,9 @@ export function App() { setQuery(e.target.value)} className="h-8 w-[210px] max-w-full grow rounded-lg border border-line bg-surface pr-2.5 pl-8 text-[13px] sm:grow-0" diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 3e4a31b9..29384e0b 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -187,6 +187,25 @@ export function Legend() { /> + + weight:xs,s} + def="review-effort class(es), comma list ORs — click a row’s weight meter to toggle it" + /> + has:action} + def="cards where you personally have a move to make" + /> + is:restamp} + def="cards where a push owes you a re-CR or re-QA" + /> + is:blocked} + def="cards under a dev or deploy block" + /> + + /} def="focus the filter" /> void; } /** @@ -427,6 +430,33 @@ function RowActionsKebab({ pull }: { pull: DerivedPull }) { ); } +/** + * The weight meter as a filter toggle: click it to narrow the board to that + * size class (`weight:xs`, …), click again to clear — the same channel the + * repo:/author: query terms already reveal through, just row-initiated. Falls + * back to the plain, non-interactive meter when no callback is wired up + * (e.g. a lens that hasn't threaded RowOptions.onQueryToken). + */ +function WeightChip({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { + const meter = ; + const onQueryToken = opts.onQueryToken; + if (!onQueryToken) return meter; + const token = `weight:${pull.weight.toLowerCase()}`; + return ( + + ); +} + /** * The metric rail every row ends on, in one order everywhere: how heavy to * review, then the CR and QA sign-off pips, then age. Right-anchored and @@ -444,7 +474,7 @@ function MetricRail({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { > - + ): DerivedPull { return { status: over.status ?? 'needs_cr', ageDays: over.ageDays ?? 0, + ci: 'success', + ciFailing: [], + crBy: [], + qaBy: [], + crHave: 0, + qaHave: 0, + recrBy: over.recrBy ?? [], + reqaBy: over.reqaBy ?? [], + headPushedAt: null, + signedOffAt: null, + starved: false, + starveScore: 0, + weight: over.weight ?? 'M', + conflict: false, + mergeUnknown: false, + dependent: false, + sizeKnown: true, + devBlockedBy: [], + deployBlockedBy: [], + qaingLogin: null, + externalBlock: false, + cryo: false, + changesRequestedBy: [], + engagedNoStamp: [], data: { title: over.title ?? 'Fix the store dropdown', repo: over.repo ?? 'acme/widgets', number: over.number ?? 12345, user: { login: over.author ?? 'alice' }, labels: (over.labels ?? []).map(title => ({ title })), + status: { cr_req: 1, qa_req: 1, unstamped_reviewers: [] }, }, } as unknown as DerivedPull; } describe('matchesQuery', () => { it('matches PR numbers, bare or #-prefixed', () => { - expect(matchesQuery(fake({ number: 63495 }), '63495')).toBe(true); - expect(matchesQuery(fake({ number: 63495 }), '#63495')).toBe(true); - expect(matchesQuery(fake({ number: 111 }), '63495')).toBe(false); + expect(matchesQuery(fake({ number: 63495 }), '63495', 'viewer')).toBe(true); + expect(matchesQuery(fake({ number: 63495 }), '#63495', 'viewer')).toBe(true); + expect(matchesQuery(fake({ number: 111 }), '63495', 'viewer')).toBe(false); }); it('bare terms AND across title, repo, author, labels', () => { const p = fake({ title: 'Store dropdown', author: 'alice', labels: ['QAE'] }); - expect(matchesQuery(p, 'store alice')).toBe(true); - expect(matchesQuery(p, 'qae')).toBe(true); - expect(matchesQuery(p, 'store bob')).toBe(false); + expect(matchesQuery(p, 'store alice', 'viewer')).toBe(true); + expect(matchesQuery(p, 'qae', 'viewer')).toBe(true); + expect(matchesQuery(p, 'store bob', 'viewer')).toBe(false); }); it('label:, status:, older:, repo:, author: tokens narrow one field', () => { const p = fake({ labels: ['QAE'], status: 'needs_recr', ageDays: 9, author: 'alice' }); - expect(matchesQuery(p, 'label:qae')).toBe(true); - expect(matchesQuery(p, 'label:frontend')).toBe(false); - expect(matchesQuery(p, 'status:needs-recr')).toBe(true); - expect(matchesQuery(p, 'status:ready')).toBe(false); - expect(matchesQuery(p, 'older:7')).toBe(true); - expect(matchesQuery(p, 'older:7d')).toBe(true); - expect(matchesQuery(p, 'older:30')).toBe(false); - expect(matchesQuery(p, 'author:ali')).toBe(true); - expect(matchesQuery(p, 'repo:widgets')).toBe(true); + expect(matchesQuery(p, 'label:qae', 'viewer')).toBe(true); + expect(matchesQuery(p, 'label:frontend', 'viewer')).toBe(false); + expect(matchesQuery(p, 'status:needs-recr', 'viewer')).toBe(true); + expect(matchesQuery(p, 'status:ready', 'viewer')).toBe(false); + expect(matchesQuery(p, 'older:7', 'viewer')).toBe(true); + expect(matchesQuery(p, 'older:7d', 'viewer')).toBe(true); + expect(matchesQuery(p, 'older:30', 'viewer')).toBe(false); + expect(matchesQuery(p, 'author:ali', 'viewer')).toBe(true); + expect(matchesQuery(p, 'repo:widgets', 'viewer')).toBe(true); }); it('an unknown key falls back to plain substring', () => { - expect(matchesQuery(fake({ title: 'weird:token in title' }), 'weird:token')).toBe(true); + expect(matchesQuery(fake({ title: 'weird:token in title' }), 'weird:token', 'viewer')).toBe( + true + ); + }); + + it('weight: matches a single class, a comma list (OR), case-insensitively', () => { + const xs = fake({ weight: 'XS' }); + const s = fake({ weight: 'S' }); + const m = fake({ weight: 'M' }); + expect(matchesQuery(xs, 'weight:xs', 'viewer')).toBe(true); + expect(matchesQuery(xs, 'weight:XS', 'viewer')).toBe(true); + expect(matchesQuery(xs, 'weight:s', 'viewer')).toBe(false); + expect(matchesQuery(xs, 'weight:xs,s', 'viewer')).toBe(true); + expect(matchesQuery(s, 'weight:xs,s', 'viewer')).toBe(true); + expect(matchesQuery(m, 'weight:xs,s', 'viewer')).toBe(false); + }); + + it('has:action matches when the viewer has an imperative move', () => { + // author of a ready PR: the author's move is "Merge it" + const ready = fake({ author: 'alice', status: 'ready' }); + expect(matchesQuery(ready, 'has:action', 'alice')).toBe(true); + // author of a fresh needs_cr PR with no reviewers yet: pure wait, no action + const waiting = fake({ author: 'alice', status: 'needs_cr' }); + expect(matchesQuery(waiting, 'has:action', 'alice')).toBe(false); + }); + + it('is:restamp matches a viewer owed in recrBy or reqaBy', () => { + const p = fake({ recrBy: ['bob'], reqaBy: ['carol'] }); + expect(matchesQuery(p, 'is:restamp', 'bob')).toBe(true); + expect(matchesQuery(p, 'is:restamp', 'carol')).toBe(true); + expect(matchesQuery(p, 'is:restamp', 'dave')).toBe(false); + }); + + it('is:blocked matches dev_block and deploy_block statuses only', () => { + expect(matchesQuery(fake({ status: 'dev_block' }), 'is:blocked', 'viewer')).toBe(true); + expect(matchesQuery(fake({ status: 'deploy_block' }), 'is:blocked', 'viewer')).toBe(true); + expect(matchesQuery(fake({ status: 'ready' }), 'is:blocked', 'viewer')).toBe(false); + }); + + it('unknown has:/is: values match nothing', () => { + expect(matchesQuery(fake({}), 'has:bogus', 'viewer')).toBe(false); + expect(matchesQuery(fake({}), 'is:bogus', 'viewer')).toBe(false); + }); + + it('composes with AND semantics: weight:xs is:blocked', () => { + const match = fake({ weight: 'XS', status: 'dev_block' }); + const wrongWeight = fake({ weight: 'M', status: 'dev_block' }); + const wrongStatus = fake({ weight: 'XS', status: 'ready' }); + expect(matchesQuery(match, 'weight:xs is:blocked', 'viewer')).toBe(true); + expect(matchesQuery(wrongWeight, 'weight:xs is:blocked', 'viewer')).toBe(false); + expect(matchesQuery(wrongStatus, 'weight:xs is:blocked', 'viewer')).toBe(false); }); }); diff --git a/frontend-v2/src/model/query.ts b/frontend-v2/src/model/query.ts index e4d3308f..a12121d4 100644 --- a/frontend-v2/src/model/query.ts +++ b/frontend-v2/src/model/query.ts @@ -1,3 +1,4 @@ +import { rowNote } from './actions'; import type { DerivedPull } from './status'; /** @@ -10,13 +11,20 @@ import type { DerivedPull } from './status'; * older:5 open at least 5 days (older:5d also accepted) * repo:ifixit repo name contains * author:al author login contains + * weight:xs,s review-effort class, comma list ORs (weight:xs,s = XS or S) + * has:action the viewer (`me`) has an imperative move on this card + * is:restamp `me` owes a re-CR or re-QA (in recrBy/reqaBy) + * is:blocked status is dev_block or deploy_block + * + * `me` is the viewer's login, needed only for has:/is: — every other token + * ignores it. */ -export function matchesQuery(p: DerivedPull, query: string): boolean { +export function matchesQuery(p: DerivedPull, query: string, me: string): boolean { const terms = query.toLowerCase().split(/\s+/).filter(Boolean); - return terms.every(t => matchTerm(p, t)); + return terms.every(t => matchTerm(p, t, me)); } -function matchTerm(p: DerivedPull, term: string): boolean { +function matchTerm(p: DerivedPull, term: string, me: string): boolean { const d = p.data; const i = term.indexOf(':'); if (i > 0) { @@ -31,6 +39,14 @@ function matchTerm(p: DerivedPull, term: string): boolean { } if (key === 'repo') return d.repo.toLowerCase().includes(val); if (key === 'author') return d.user.login.toLowerCase().includes(val); + if (key === 'weight') + return val.split(',').filter(Boolean).includes(p.weight.toLowerCase()); + if (key === 'has') return val === 'action' && rowNote(p, me).action != null; + if (key === 'is') { + if (val === 'restamp') return p.recrBy.includes(me) || p.reqaBy.includes(me); + if (val === 'blocked') return p.status === 'dev_block' || p.status === 'deploy_block'; + return false; + } // unknown key: treat the whole term as a plain substring below } } From 41786ba7bed47ee675f6d05c14a295aa79780b14 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 00:27:39 -0700 Subject: [PATCH 136/381] Drop the redundant muted-count chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "4 muted repos" pill sat directly beside the Repos trigger whose active label already reads "4 muted", and clicking it opened the same popover the trigger does — a duplicate button for the fact next door. Same for muted people. With them gone the chip grammar is simpler and stricter: every chip is session state and every chip is dismissible; durable state lives on the triggers that own it. The ref plumbing that existed only to let those pills open the popovers goes too. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 17 ---- .../src/components/filters/FilterChips.tsx | 81 +++++-------------- .../src/components/filters/PeopleFilter.tsx | 6 +- .../src/components/filters/RepoFilter.tsx | 6 +- 4 files changed, 24 insertions(+), 86 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 5df24a4b..e1c5cf67 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -214,17 +214,6 @@ export function App() { ); const dark = settings.theme === 'dark' || (settings.theme === 'system' && systemDark); const searchRef = useRef(null); - // FilterChips' durable "muted repos"/"muted people" pills have no state of - // their own to toggle — they open the matching filter popover instead, by - // clicking its (otherwise-internal) trigger button through this ref - const repoFilterRef = useRef(null); - const peopleFilterRef = useRef(null); - const openRepoFilter = useCallback(() => { - repoFilterRef.current?.querySelector('button')?.click(); - }, []); - const openPeopleFilter = useCallback(() => { - peopleFilterRef.current?.querySelector('button')?.click(); - }, []); useEffect(() => { void loadSiteConfig().then(c => { @@ -612,7 +601,6 @@ export function App() { {tab('stats', 'Stats')} {legacy && ( void; - onOpen?: () => void; + /** clears just this dimension */ + onClear: () => void; } /** - * The header's chip row: one pill per ACTIVE filter dimension, not per item — - * "3 people" reads at a glance where three separate name chips didn't. Scope - * and session pills are dismissible (×, clears just that dimension); the - * muted-repos/muted-people pills are informational buttons that open the - * matching filter instead, because mutes are durable board state, not - * something a header chip should silently wipe out. + * The header's chip row: one dismissible pill per ACTIVE session filter + * dimension, not per item — "3 people" reads at a glance where three separate + * name chips didn't. Durable state (mutes, stars) gets no chip: the Repos and + * People triggers already carry those counts in their own active labels, so a + * chip would just repeat the button next to it. */ export function FilterChips({ - repos, - orgHidden, reveal, toggleReveal, showAll, @@ -31,11 +24,7 @@ export function FilterChips({ setDraftsMode, scope, setScope, - onOpenRepoFilter, - onOpenPeopleFilter, }: { - repos: { name: string; count: number }[]; - orgHidden: ReadonlySet; reveal: string[]; toggleReveal: (key: string) => void; showAll: boolean; @@ -44,14 +33,8 @@ export function FilterChips({ setDraftsMode: (m: 'mine' | 'all') => void; scope: { repos: string[]; authors: string[] }; setScope: (next: { repos: string[]; authors: string[] }) => void; - onOpenRepoFilter: () => void; - onOpenPeopleFilter: () => void; }) { const settings = useSettings(); - const mutedRepoCount = repos.filter( - r => repoState(r.name, orgHidden, settings.repoPrefs) === 'muted' - ).length; - const mutedPeopleCount = settings.mutedPeople.length; // an individually-revealed repo/cryo, outside full showAll — "hidden shown" // covers both; cryo gets its own pill only when it's revealed on its own // (showAll already says "everything," so a second cryo pill would be noise) @@ -71,18 +54,6 @@ export function FilterChips({ label: `${scope.authors.length} ${scope.authors.length > 1 ? 'people' : 'person'}`, onClear: () => setScope({ ...scope, authors: [] }), }); - if (mutedRepoCount) - pills.push({ - key: 'muted-repos', - label: `${mutedRepoCount} muted repo${mutedRepoCount > 1 ? 's' : ''}`, - onOpen: onOpenRepoFilter, - }); - if (mutedPeopleCount) - pills.push({ - key: 'muted-people', - label: `${mutedPeopleCount} muted ${mutedPeopleCount > 1 ? 'people' : 'person'}`, - onOpen: onOpenPeopleFilter, - }); if (draftsMode === 'all' && draftsMode !== settings.draftsMode) pills.push({ key: 'drafts', @@ -121,34 +92,22 @@ export function FilterChips({ return (
- {pills.map(p => - p.onClear ? ( - - {p.label} - - - ) : ( + {pills.map(p => ( + + {p.label} - ) - )} + + ))} {clearable && ( + )} + > + {STATE_OPTIONS.map(({ key, label }) => ( + + + + ))} + +
+ ); +} diff --git a/frontend-v2/src/components/filters/WeightFilter.tsx b/frontend-v2/src/components/filters/WeightFilter.tsx new file mode 100644 index 00000000..1f4bbbc9 --- /dev/null +++ b/frontend-v2/src/components/filters/WeightFilter.tsx @@ -0,0 +1,99 @@ +import type { DerivedPull } from '../../model/status'; +import { weightFilterKey } from '../../model/status'; +import { Popover } from '../Popover'; +import { FilterRow } from './shared'; + +/** Weight filter option order: lightest to heaviest, then the unknown-size + * catch-all — mirrors the WeightMeter's own light-to-heavy read. */ +const WEIGHT_OPTIONS: { key: string; label: string }[] = [ + { key: 'xs', label: 'XS' }, + { key: 's', label: 'S' }, + { key: 'm', label: 'M' }, + { key: 'l', label: 'L' }, + { key: 'xl', label: 'XL' }, + { key: 'unknown', label: 'Size unknown' }, +]; + +/** + * The point-and-click twin of the `weight:` query token: narrow the board to + * one or more review-effort classes. `pulls` is the pre-weight-filtered + * pool (app.tsx's scoped list before this filter's own pass applies) so a + * selection never makes the *other* options' counts vanish — the same + * precedent PeopleFilter's authorCounts sets for its own pool. + */ +export function WeightFilter({ + pulls, + weightSel, + setWeightSel, +}: { + pulls: DerivedPull[]; + weightSel: string[]; + setWeightSel: (next: string[]) => void; +}) { + const counts = new Map(); + for (const p of pulls) { + const key = weightFilterKey(p); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + + const toggle = (key: string) => { + const next = weightSel.includes(key) ? weightSel.filter(k => k !== key) : [...weightSel, key]; + setWeightSel(next); + }; + + const active = weightSel.length > 0; + const summary = active ? `Weight · ${weightSel.join(', ')}` : 'Weight'; + + return ( +
+ ( + + )} + > + {WEIGHT_OPTIONS.map(({ key, label }) => ( + + + + ))} + +
+ ); +} From 231431fbff1e927da56f0f8fcfb398c9c9cfa4cb Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 00:51:44 -0700 Subject: [PATCH 139/381] Make the merge-age chart readable: one slowest mark, honest bar scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-day max ticks rendered as sixty disconnected specks at 60-column density — a dot lattice, not information — and worse, the outliers set the y-scale, compressing every average bar into an identical 8%-floor stub. Two failures hiding the one thing the chart exists to show. Bars now scale to the average series so daily variation is visible, and the outlier story gets exactly one mark: the window's slowest merge is pinned to the top of its day and named in the summary line. Every day's slowest still lives in its tooltip. PeriodColumns' scale ignores markers generally now — a callout should never drag the data down. Co-Authored-By: Claude Opus 4.8 --- .../src/views/stats/MergeAgeByDayCard.tsx | 29 +++++++++++++++---- frontend-v2/src/views/stats/parts.tsx | 4 ++- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/frontend-v2/src/views/stats/MergeAgeByDayCard.tsx b/frontend-v2/src/views/stats/MergeAgeByDayCard.tsx index 9f5b2d62..138f5485 100644 --- a/frontend-v2/src/views/stats/MergeAgeByDayCard.tsx +++ b/frontend-v2/src/views/stats/MergeAgeByDayCard.tsx @@ -3,9 +3,13 @@ import type { MergeAgeDayRow } from '../../model/statsHistory'; import { PeriodColumns, StatsCard } from './parts'; /** - * Merge age (open→merge) per day, last 60 days: the day's average as the bar, - * its slowest merge as a thin marker above — so a single bad outlier doesn't - * vanish inside a tame-looking daily average. + * Merge age (open→merge) per day, last 60 days. Bars are the daily average, + * scaled to the average series — NOT to the outliers, which used to compress + * every bar into an identical stub. The outlier story gets exactly one mark: + * the window's slowest merge is flagged on its day and named in the summary + * line. (A per-day max tick at 60-column density rendered as sixty floating + * specks — noise, not information; each day's slowest still lives in its + * tooltip.) */ export function MergeAgeByDayCard({ days }: { days: MergeAgeDayRow[] }) { const merged = days.reduce((a, d) => a + d.merged, 0); @@ -13,15 +17,21 @@ export function MergeAgeByDayCard({ days }: { days: MergeAgeDayRow[] }) { const overallAvg = withData.length ? withData.reduce((a, d) => a + d.avgHours, 0) / withData.length : 0; + const slowest = withData.reduce( + (worst: MergeAgeDayRow | null, d) => (d.maxHours > (worst?.maxHours ?? 0) ? d : worst), + null + ); const periods = days.map(d => ({ key: d.day, title: `${d.day}: avg ${humanHours(d.avgHours)} · slowest ${humanHours(d.maxHours)} · ${d.merged} merged`, value: d.avgHours, - marker: d.maxHours, + // one mark in the whole chart: the window's slowest merge — its real + // value, which the marker clamp pins to the top edge of its column + marker: slowest && d.day === slowest.day ? d.maxHours : undefined, })); return ( - +
@@ -32,6 +42,15 @@ export function MergeAgeByDayCard({ days }: { days: MergeAgeDayRow[] }) { · avg{' '} {humanHours(overallAvg)} + {slowest && ( + <> + · + slowest{' '} + + {humanHours(slowest.maxHours)} + + + )} ) : ( No merges in this window. diff --git a/frontend-v2/src/views/stats/parts.tsx b/frontend-v2/src/views/stats/parts.tsx index 65ee0b50..41ef6006 100644 --- a/frontend-v2/src/views/stats/parts.tsx +++ b/frontend-v2/src/views/stats/parts.tsx @@ -150,7 +150,9 @@ export function PeriodColumns({ color: string; axisTicks?: AxisTick[]; }) { - const max = Math.max(...periods.map(p => Math.max(p.value, p.marker ?? 0)), 1); + // scale to the value series only — a marker (outlier callout) clamps to the + // top edge instead of dragging every bar down into an unreadable stub + const max = Math.max(...periods.map(p => p.value), 1); return (
From 6b2a46a5331900ddc86c022d47eee5b58f143f11 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 01:04:58 -0700 Subject: [PATCH 140/381] Nest stacked PRs under their base in every lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked PRs rendered as unrelated rows with a prose flag naming a branch. Now a dependent PR whose parent sits in the same rendered list indents beneath it with a hairline elbow, chains recurse with the visual depth capped at two, and the now-redundant stacked flag drops off nested rows. groupIntoTree runs after each lane's own sort — the stack occupies the parent's position, children follow in order — and lane membership never changes: a blocked child stays in the blocked fold. Nesting is presentation, not reorganization. Parent resolution keys on repo + head ref over the local list only, and any ambiguity (the fixture ships a real duplicate-head case) renders flat rather than guessing. Cycles chase iteratively, no recursion. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/backend/dummy.ts | 38 +++++- frontend-v2/src/components/Card.tsx | 33 ++++- frontend-v2/src/components/Lane.tsx | 11 +- frontend-v2/src/model/stack.test.ts | 186 ++++++++++++++++++++++++++++ frontend-v2/src/model/stack.ts | 149 ++++++++++++++++++++++ frontend-v2/src/views/Classic.tsx | 6 +- frontend-v2/src/views/MyWork.tsx | 11 +- frontend-v2/src/views/Review.tsx | 8 +- 8 files changed, 422 insertions(+), 20 deletions(-) create mode 100644 frontend-v2/src/model/stack.test.ts create mode 100644 frontend-v2/src/model/stack.ts diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts index 44f1cc0e..dedff519 100644 --- a/frontend-v2/src/backend/dummy.ts +++ b/frontend-v2/src/backend/dummy.ts @@ -19,7 +19,7 @@ export async function loadDummy(): Promise { .default as unknown as PullData[]; // The fixture is a decade of frozen pulls; re-date them so age-derived // signals (heat, starvation, freshness) exercise realistically. - const pulls = raw.map((p, i) => redate(p, i)); + const pulls = withSyntheticStacks(raw).map((p, i) => redate(p, i)); return { repos: [{ name: 'iFixit/ifixit' }], // The fixture carries almost no closed/merged pulls and no diff sizes, so @@ -56,7 +56,8 @@ function redateSigs( // and `status.unstamped_reviewers` to say "answer their review" / "in // discussion with" — both postdate this fixture, so a few fixed pulls carry // them here (deterministic indexes, no randomness) to keep those nudges -// QA-able in dummy mode. review_id/body let the FeedbackPopover trigger too. +// QA-able in dummy mode. review_id/body let the state popover's feedback +// section trigger too. const CHANGES_REQUESTED_INDEX = 2; const COMMENTED_INDEX = 8; const EXTRA_PARTICIPANT_INDEXES = new Set([2, 5, 6, 8]); @@ -91,10 +92,10 @@ function unstampedReviewerFor( } /** - * A dev-blocked dummy pull's block signature needs a comment_id so the - * FeedbackPopover can build a GitHub permalink for it (signatureUrl). The - * fixture's dev_block signatures already carry one, but this keeps the demo - * honest if that ever stops being true. + * A dev-blocked dummy pull's block signature needs a comment_id so the state + * popover can build a GitHub permalink for it (signatureUrl). The fixture's + * dev_block signatures already carry one, but this keeps the demo honest if + * that ever stops being true. */ function withDevBlockCommentId(status: PullData['status']): PullData['status'] { if (!status.dev_block.some(s => !s.data.comment_id)) return status; @@ -122,6 +123,31 @@ function participantsFor(pull: PullData, i: number): string[] { return [...new Set([...(pull.participants ?? []), ...stampers, ...extra])]; } +// Fixed raw-fixture indices rewritten into two stacked-PR demos for +// model/stack.ts's groupIntoTree (deterministic, same spirit as the rest of +// this file's index-keyed synthesis): a 3-deep chain (#35168 -> #35177 -> +// #35194) and one parent with two children (#35207 -> #35236, #35249). Left +// untouched: indices 5-7, where the fixture already carries the ambiguous- +// parent case unassisted — #35103 and #351011 share a head ref, and #35059 +// (index 5) already bases off it, so groupIntoTree's flat fallback is +// QA-able without any synthesis here. +const CHAIN_INDEXES = [10, 11, 12] as const; // parent, child, grandchild +const FORK_INDEXES = [13, 14, 15] as const; // parent, child, child + +function withSyntheticStacks(pulls: PullData[]): PullData[] { + const out = [...pulls]; + const rebase = (i: number, baseRef: string) => { + out[i] = { ...out[i], base: { ...out[i].base, ref: baseRef } }; + }; + const [chainParent, chainChild, chainGrandchild] = CHAIN_INDEXES; + rebase(chainChild, out[chainParent].head.ref); + rebase(chainGrandchild, out[chainChild].head.ref); + const [forkParent, forkChild1, forkChild2] = FORK_INDEXES; + rebase(forkChild1, out[forkParent].head.ref); + rebase(forkChild2, out[forkParent].head.ref); + return out; +} + function redate(pull: PullData, i: number): PullData { const now = Date.now(); const ageDays = (i * 7919) % 45; // deterministic spread, 0-45 days diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index babc4561..bf7834eb 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -19,6 +19,24 @@ import { Avatar, PullTitleLink } from './bits'; * title link stretches over the whole card (`stretch`), so a click anywhere * opens the PR; genuinely interactive children opt back out with `.pd-raise`. */ +/** Per-depth left inset for a stacked (nested) row: enough to read as a + * child without eating too much width at deep nesting. */ +const STACK_INDENT_PX = 22; + +/** The elbow before a nested row's avatar — the one hairline that says + * "child of the row above", quiet enough to disappear when there's nothing + * to connect (depth 0 renders nothing). */ +function StackConnector({ compact }: { compact?: boolean }) { + return ( + + └ + + ); +} + export function CardShell({ login, onPerson, @@ -32,6 +50,7 @@ export function CardShell({ stretch = true, compact = false, avatarBadge, + depth = 0, }: { login: string; onPerson?: (login: string) => void; @@ -53,16 +72,24 @@ export function CardShell({ * author ★) — a slot rather than an Avatar prop, so this stays a one- * caller concern instead of touching every Avatar call site. */ avatarBadge?: ReactNode; + /** stack-nesting depth (0 = top-level): indents the row and shows a + * connector elbow before the avatar — model/stack.ts's groupIntoTree + * supplies it. Capped at 2 by the model; the geometry doesn't need its + * own cap on top of that. */ + depth?: number; }) { const titleLink = ( ); + const connector = depth > 0 && ; if (compact) { return (
+ {connector} {/* raise only when the avatar is a real button — a raised inert span punches a dead zone into the whole-row click target */} @@ -85,8 +112,10 @@ export function CardShell({ return (
+ {connector && {connector}} {avatarBadge} diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index 0f3dee29..815aa09a 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -1,6 +1,7 @@ import { useState, type ReactNode, type SyntheticEvent } from 'react'; import { pullKey } from '../format'; import type { DerivedPull } from '../model/status'; +import { groupIntoTree } from '../model/stack'; import { createPersistentStore } from '../storage'; import { Row, type RowOptions } from './Row'; @@ -88,6 +89,7 @@ export function Lane({ }) { if (!pulls.length && !children) return null; const shown = laneShown(cap, opts); + const tree = groupIntoTree(pulls); return (
{children} - {pulls.map(p => ( - + {tree.map(({ pull: p, depth }) => ( + ))} @@ -119,10 +121,11 @@ export function FoldRows({ /** stable identity: remembers "+N more" expansion across unmounts this session */ id?: string; }) { + const tree = groupIntoTree(list); return ( - {list.map(p => ( - + {tree.map(({ pull: p, depth }) => ( + ))} ); diff --git a/frontend-v2/src/model/stack.test.ts b/frontend-v2/src/model/stack.test.ts new file mode 100644 index 00000000..b86ed16d --- /dev/null +++ b/frontend-v2/src/model/stack.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; +import type { PullData } from '../types'; +import type { DerivedPull } from './status'; +import { groupIntoTree } from './stack'; + +/** + * A minimal DerivedPull fixture: groupIntoTree only reads repo/number, + * head.ref, base.ref, and `dependent` — everything else here is filler to + * satisfy the type, same spirit as status.test.ts's `pull()` helper. + */ +function dp( + number: number, + headRef: string, + baseRef: string, + opts: { repo?: string; owner?: string } = {} +): DerivedPull { + const repo = opts.repo ?? 'iFixit/ifixit'; + const data: PullData = { + repo, + number, + state: 'open', + title: `Pull #${number}`, + body: '', + draft: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + closed_at: null, + merged_at: null, + mergeable: true, + difficulty: null, + additions: 10, + deletions: 5, + changed_files: 2, + milestone: { title: null, due_on: null }, + head: { + ref: headRef, + sha: `sha-${number}`, + repo: { owner: { login: opts.owner ?? 'iFixit' } }, + }, + base: { ref: baseRef }, + user: { login: 'author' }, + status: { + cr_req: 1, + qa_req: 1, + allCR: [], + allQA: [], + dev_block: [], + deploy_block: [], + commit_statuses: [], + }, + labels: [], + participants: [], + }; + return { + data, + status: 'needs_cr', + ci: 'none', + ciFailing: [], + crBy: [], + qaBy: [], + crHave: 0, + qaHave: 0, + recrBy: [], + reqaBy: [], + headPushedAt: null, + ageDays: 0, + signedOffAt: null, + starved: false, + starveScore: 0, + weight: 'S', + conflict: false, + mergeUnknown: false, + dependent: !['main', 'master'].includes(baseRef), + sizeKnown: true, + devBlockedBy: [], + deployBlockedBy: [], + qaingLogin: null, + externalBlock: false, + cryo: false, + changesRequestedBy: [], + engagedNoStamp: [], + }; +} + +const depths = (out: ReturnType) => + out.map(({ pull, depth }) => [pull.data.number, depth]); + +describe('groupIntoTree', () => { + it('passes an empty list through untouched', () => { + expect(groupIntoTree([])).toEqual([]); + }); + + it('passes a list with no stacked pulls through in the same order', () => { + const a = dp(1, 'a', 'master'); + const b = dp(2, 'b', 'master'); + expect(depths(groupIntoTree([a, b]))).toEqual([ + [1, 0], + [2, 0], + ]); + }); + + it('nests a child right after its parent, depth 1', () => { + const parent = dp(1, 'feature-a', 'master'); + const child = dp(2, 'feature-b', 'feature-a'); + // input order deliberately reversed: the parent must still lead + expect(depths(groupIntoTree([child, parent]))).toEqual([ + [1, 0], + [2, 1], + ]); + }); + + it('caps reported depth at 2 for a chain deeper than that', () => { + const a = dp(1, 'a', 'master'); + const b = dp(2, 'b', 'a'); + const c = dp(3, 'c', 'b'); + const d = dp(4, 'd', 'c'); // great-grandchild: natural depth 3, capped to 2 + expect(depths(groupIntoTree([a, b, c, d]))).toEqual([ + [1, 0], + [2, 1], + [3, 2], + [4, 2], + ]); + }); + + it('one parent, two children: both nest, in their input-relative order', () => { + const parent = dp(1, 'base-feature', 'master'); + const child1 = dp(2, 'c1', 'base-feature'); + const child2 = dp(3, 'c2', 'base-feature'); + expect(depths(groupIntoTree([parent, child1, child2]))).toEqual([ + [1, 0], + [2, 1], + [3, 1], + ]); + }); + + it('an ambiguous parent (two pulls share the head ref) renders flat', () => { + // mirrors the dummy fixture's #35103/#351011: same repo, same head ref + const twin1 = dp(35103, 'shared-ref', 'master'); + const twin2 = dp(351011, 'shared-ref', 'master'); + const child = dp(35059, 'child-ref', 'shared-ref'); + const out = groupIntoTree([twin1, twin2, child]); + expect(depths(out)).toEqual([ + [35103, 0], + [351011, 0], + [35059, 0], + ]); + }); + + it('a fork twin (different owners, same branch name) is ambiguous too', () => { + const fork1 = dp(1, 'shared-ref', 'master', { owner: 'alice' }); + const fork2 = dp(2, 'shared-ref', 'master', { owner: 'bob' }); + const child = dp(3, 'child-ref', 'shared-ref'); + expect(depths(groupIntoTree([fork1, fork2, child]))).toEqual([ + [1, 0], + [2, 0], + [3, 0], + ]); + }); + + it('a parent absent from the list renders the child flat', () => { + const orphan = dp(1, 'child-ref', 'some-branch-not-on-this-board'); + expect(depths(groupIntoTree([orphan]))).toEqual([[1, 0]]); + }); + + it('a base<->base cycle renders flat and does not hang', () => { + const a = dp(1, 'ref-a', 'ref-b'); + const b = dp(2, 'ref-b', 'ref-a'); + expect(depths(groupIntoTree([a, b]))).toEqual([ + [1, 0], + [2, 0], + ]); + }); + + it('does not confuse pulls with the same head ref across different repos', () => { + const parent = dp(1, 'shared', 'master', { repo: 'iFixit/ifixit' }); + const otherRepoTwin = dp(2, 'shared', 'master', { repo: 'iFixit/server-templates' }); + const child = dp(3, 'child', 'shared', { repo: 'iFixit/ifixit' }); + // child (3) moves to right after its real parent (1); the same-named + // pull in the other repo (2) can't match it and stays a plain root + expect(depths(groupIntoTree([parent, otherRepoTwin, child]))).toEqual([ + [1, 0], + [3, 1], + [2, 0], + ]); + }); +}); diff --git a/frontend-v2/src/model/stack.ts b/frontend-v2/src/model/stack.ts new file mode 100644 index 00000000..dfef0669 --- /dev/null +++ b/frontend-v2/src/model/stack.ts @@ -0,0 +1,149 @@ +import { githubUrl, pullKey } from '../format'; +import type { DerivedPull } from './status'; + +/** + * A pull paired with its stack-nesting depth (0 = top-level). Board render + * order: a resolved child immediately follows its parent, recursing through + * chains — the tree flattened back into a list, the way a Row can render it. + */ +export interface StackedPull { + pull: DerivedPull; + depth: number; +} + +/** Deeper nests still exist (a five-deep chain happens), but the UI only + * carries geometry for two indent levels — everything past that reports 2. */ +const MAX_DEPTH = 2; + +const keyOf = (p: DerivedPull) => pullKey(p.data); + +/** Every pull sharing a (repo, head ref), regardless of fork owner. */ +function headRefIndex(pulls: DerivedPull[]): Map { + const byHeadRef = new Map(); + for (const p of pulls) { + const k = `${p.data.repo}|${p.data.head.ref}`; + const bucket = byHeadRef.get(k); + if (bucket) bucket.push(p); + else byHeadRef.set(k, [p]); + } + return byHeadRef; +} + +/** + * A pull's immediate parent, or null when it isn't a resolvable child: not + * dependent, no pull in the index has a matching head ref, or more than one + * does. Two different forks can name a branch the same, and the dummy + * fixture's own #35103/#351011 shows the same fork opening two pulls off one + * branch — either way, a base ref matching more than one head is a guess this + * never takes; the pull renders flat instead. + */ +function resolveParent( + byHeadRef: ReadonlyMap, + p: DerivedPull +): DerivedPull | null { + if (!p.dependent) return null; + const candidates = byHeadRef.get(`${p.data.repo}|${p.data.base.ref}`); + if (!candidates || candidates.length !== 1) return null; + return candidates[0] !== p ? candidates[0] : null; +} + +/** + * Reorders a pull list so a pull based on another pull's head branch (both in + * THIS list) renders right after that parent, indented by nesting depth. + * Parent resolution only ever looks within `pulls` — a parent that's off this + * particular lane/lens (closed, filtered, on another tab) can't be found + * here; see `buildParentLookup` for the whole-board fallback that names it + * anyway. + * + * Same length and content as the input, just reordered — nothing is dropped, + * nothing is invented. + */ +export function groupIntoTree(pulls: DerivedPull[]): StackedPull[] { + const byHeadRef = headRefIndex(pulls); + + const parentOf = new Map(); + for (const p of pulls) parentOf.set(keyOf(p), resolveParent(byHeadRef, p)); + + // Cycle guard: a<->b (or longer) base loops must not hang the walk below. + // parentOf is a functional graph (out-degree <= 1 per node), so one pass + // per unvisited node — tracking the current walk's path — finds every + // cycle in O(n) total. Cyclical pulls render flat: neither side of a loop + // can consistently be "above" the other. + const settled = new Set(); + const cyclical = new Set(); + for (const p of pulls) { + const start = keyOf(p); + if (settled.has(start)) continue; + const path: string[] = []; + const onPath = new Set(); + let curKey: string | undefined = start; + while (curKey !== undefined && !settled.has(curKey)) { + if (onPath.has(curKey)) { + const idx = path.indexOf(curKey); + for (const k of path.slice(idx)) cyclical.add(k); + break; + } + onPath.add(curKey); + path.push(curKey); + const parent = parentOf.get(curKey); + curKey = parent ? keyOf(parent) : undefined; + } + for (const k of path) settled.add(k); + } + for (const k of cyclical) parentOf.set(k, null); + + // Children in input-relative order, one list per resolved parent. + const childrenOf = new Map(); + for (const p of pulls) { + const parent = parentOf.get(keyOf(p)); + if (!parent) continue; + const pk = keyOf(parent); + const list = childrenOf.get(pk); + if (list) list.push(p); + else childrenOf.set(pk, [p]); + } + + const out: StackedPull[] = []; + const place = (p: DerivedPull, depth: number) => { + out.push({ pull: p, depth }); + for (const child of childrenOf.get(keyOf(p)) ?? []) { + place(child, Math.min(depth + 1, MAX_DEPTH)); + } + }; + // Roots keep the input's relative order; each one's children are placed + // recursively right after it, so nothing needs a second sorting pass. + for (const p of pulls) { + if (parentOf.get(keyOf(p))) continue; // placed already, under its parent + place(p, 0); + } + return out; +} + +/** What a resolved parent looks like once it's off the current list: enough + * to name it and link to it, nothing more. */ +export interface ParentRef { + number: number; + title: string; + url: string; +} + +/** + * The whole-board parent lookup: same resolution rule as `groupIntoTree`, but + * over every pull on the board — not just the filtered/scoped list a given + * lane renders — so a pull whose parent exists somewhere else (a different + * lane, a muted repo, another lens' scope) can still be named instead of just + * saying "based on ". Build once (memoized over the full pull set in + * app.tsx) and thread through RowOptions.parentOf. + */ +export function buildParentLookup(allPulls: DerivedPull[]): (p: DerivedPull) => ParentRef | null { + const byHeadRef = headRefIndex(allPulls); + return p => { + const parent = resolveParent(byHeadRef, p); + if (!parent) return null; + return { + number: parent.data.number, + title: parent.data.title, + url: githubUrl(parent.data.repo, parent.data.number), + }; + }; +} diff --git a/frontend-v2/src/views/Classic.tsx b/frontend-v2/src/views/Classic.tsx index a933056a..e6d2ea85 100644 --- a/frontend-v2/src/views/Classic.tsx +++ b/frontend-v2/src/views/Classic.tsx @@ -1,4 +1,5 @@ import { crDone, qaDone, type DerivedPull } from '../model/status'; +import { groupIntoTree } from '../model/stack'; import type { PullData } from '../types'; import { ago, closedEpoch, pullKey } from '../format'; import { ClosedBadge, EmptyState, RepoRef } from '../components/bits'; @@ -62,12 +63,13 @@ function Column({ opts: RowOptions; defaultOpen?: boolean; }) { + const tree = groupIntoTree(pulls); return ( {/* a 60-row CR column is a 3600px scroll: cap it, keep the count honest */} - {pulls.map(p => ( - + {tree.map(({ pull: p, depth }) => ( + ))} diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index 201c1eb1..8bc56040 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -1,5 +1,6 @@ import type { DerivedPull } from '../model/status'; import { authorMove } from '../model/actions'; +import { groupIntoTree } from '../model/stack'; import type { PullData } from '../types'; import { pullKey } from '../format'; import { EmptyState } from '../components/bits'; @@ -29,6 +30,8 @@ export function MyWork({ const move = mine.filter(p => authorMove(p) !== null).sort(byUrgency); const waiting = mine.filter(p => authorMove(p) === null).sort(byUrgency); const shipped = closed.filter(p => p.user.login === me); + const moveTree = groupIntoTree(move); + const waitingTree = groupIntoTree(waiting); if (!mine.length && !shipped.length) { return ( @@ -42,8 +45,8 @@ export function MyWork({ return ( <> - {move.map(p => ( - + {moveTree.map(({ pull: p, depth }) => ( + ))} {!move.length && (
@@ -52,8 +55,8 @@ export function MyWork({ )} - {waiting.map(p => ( - + {waitingTree.map(({ pull: p, depth }) => ( + ))} {!waiting.length && (
diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index f34c572b..28f49629 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -1,6 +1,7 @@ import { type DerivedPull, qaDone, type Status, weightRank } from '../model/status'; import { pullKey } from '../format'; import { crSort, starFirst } from '../model/sort'; +import { groupIntoTree } from '../model/stack'; import { authorMove, reviewerMove } from '../model/actions'; import { useSettings } from '../settings'; import { isFresh } from '../store'; @@ -70,6 +71,9 @@ export function Review({ (a, b) => MOVE_RANK.indexOf(a.verb) - MOVE_RANK.indexOf(b.verb) || b.p.ageDays - a.p.ageDays ); + // Row recomputes its own verb via rowNote, so reordering by stack (and + // dropping the { p, verb } wrapper) loses nothing the row needs. + const todoTree = groupIntoTree(todo.map(({ p }) => p)); // 2. Review queue: best next review first (leverage + age + weight). // Includes pulls waiting on someone else's re-stamp — a fresh CR from @@ -192,8 +196,8 @@ export function Review({ {todo.length > 0 && ( - {todo.map(({ p }) => ( - + {todoTree.map(({ pull: p, depth }) => ( + ))} From 0e95d8e73d7c5ac39d984399632016b282c142e4 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 01:04:58 -0700 Subject: [PATCH 141/381] Give every card a state popover, even brand-new PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feedback popover only existed where feedback existed; a fresh PR with zero activity answered hover with nothing. The status badge is now the universal trigger on every card: state restated in a sentence plus your move when you have one, the facts (opened when and by whom, CR and QA progress with names, stale stamps in amber, CI contexts when red), the feedback excerpt rows where sources exist, and a GitHub link always. Closed rows get the receipt version. Same popover, two doors — the context text still opens it where feedback lives. Dependent PRs whose parent is elsewhere on the board now name it: "stacked on #N" with the parent's title and link, via a whole-board parent lookup memoized once — the base-branch prose remains only when the parent truly isn't open anywhere. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 7 + frontend-v2/src/components/ClosedRow.tsx | 9 +- .../src/components/FeedbackPopover.tsx | 122 ------- frontend-v2/src/components/Row.tsx | 106 ++++-- frontend-v2/src/components/StatePopover.tsx | 302 ++++++++++++++++++ 5 files changed, 401 insertions(+), 145 deletions(-) delete mode 100644 frontend-v2/src/components/FeedbackPopover.tsx create mode 100644 frontend-v2/src/components/StatePopover.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 8946d58e..8d1de3f1 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -12,6 +12,7 @@ import type { ActionStateKey } from './model/actions'; import { actionState } from './model/actions'; import type { DerivedPull } from './model/status'; import { matchesWeightFilter } from './model/status'; +import { buildParentLookup } from './model/stack'; import type { Team as TeamGroup } from './types'; import { isSnoozed, setWeightLabels, usePulldasher } from './store'; import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy'; @@ -563,6 +564,10 @@ export function App() { const onWeightToggle = useCallback((w: string) => { setWeightSel(cur => (cur.includes(w) ? cur.filter(k => k !== w) : [...cur, w])); }, []); + // the whole-board parent lookup for stacked pulls: built once over every + // pull (not the scoped/filtered view a given lane renders), so a row whose + // parent got filtered out of ITS list can still name it (see model/stack.ts) + const parentOf = useMemo(() => buildParentLookup(pulls), [pulls]); // stable identity so memo(Row) can skip untouched rows on socket bursts const rowOpts: RowOptions = useMemo( () => ({ @@ -575,6 +580,7 @@ export function App() { ageRotDays: settings.ageRotDays, compact: settings.density === 'compact', laneCap: settings.laneCap, + parentOf, }), [ me, @@ -586,6 +592,7 @@ export function App() { settings.ageRotDays, settings.density, settings.laneCap, + parentOf, ] ); diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index cac8b339..66879d0d 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -1,7 +1,8 @@ import { memo } from 'react'; import type { PullData } from '../types'; import { ago, closedEpoch } from '../format'; -import { Avatar, ClosedBadge, PullTitleLink, RepoRef } from './bits'; +import { Avatar, PullTitleLink, RepoRef } from './bits'; +import { ClosedBadgeTrigger } from './StatePopover'; /** * Full-width row: the badge, avatar, and right cluster are all flex-none, so @@ -30,9 +31,9 @@ export const ClosedRow = memo(function ClosedRow({ title={`${merged ? 'merged' : 'closed'} since your last look`} /> )} - - {/* nothing here is interactive, so nothing is raised: the whole row - stays one uninterrupted click target for the stretched title link */} + + + diff --git a/frontend-v2/src/components/FeedbackPopover.tsx b/frontend-v2/src/components/FeedbackPopover.tsx deleted file mode 100644 index bd5890f1..00000000 --- a/frontend-v2/src/components/FeedbackPopover.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import type { DerivedPull } from '../model/status'; -import { ago, epoch, githubUrl, signatureUrl } from '../format'; -import { Avatar } from './bits'; -import { Popover } from './Popover'; - -interface FeedbackSource { - key: string; - login: string; - /** "blocked" / "changes requested" / "commented" */ - stateWord: string; - atEpoch: number; - body?: string; - url: string; -} - -function reviewStateWord(state: string): string { - return state === 'CHANGES_REQUESTED' ? 'changes requested' : 'commented'; -} - -/** CHANGES_REQUESTED sorts ahead of COMMENTED/DISMISSED — the harsher verdict - * is the more actionable one to see first. */ -function reviewRank(state: string): number { - return state === 'CHANGES_REQUESTED' ? 0 : 1; -} - -/** - * Every feedback source behind a pull's context line, in the order they - * should read: active dev/deploy blocks first (the reason the pull can't - * move at all), then unstamped reviewers (changes-requested before mere - * comments). Empty when the pull has nothing feedback-shaped to show, in - * which case the caller renders plain text instead of a popover trigger. - */ -function feedbackSources(p: DerivedPull): FeedbackSource[] { - const d = p.data; - - const blockSources: FeedbackSource[] = [...d.status.dev_block, ...d.status.deploy_block] - .filter(s => s.data.active) - .map(s => ({ - key: `block-${s.data.comment_id}`, - login: s.data.user.login, - stateWord: 'blocked', - atEpoch: epoch(s.data.created_at), - url: signatureUrl(s), - })); - - const reviewerSources: FeedbackSource[] = [...(d.status.unstamped_reviewers ?? [])] - .sort((a, b) => reviewRank(a.state) - reviewRank(b.state)) - .map((r, i) => ({ - key: `review-${r.login}-${i}`, - login: r.login, - stateWord: reviewStateWord(r.state), - atEpoch: r.date, - body: r.body, - url: - r.review_id != null - ? `${githubUrl(d.repo, d.number)}#pullrequestreview-${r.review_id}` - : githubUrl(d.repo, d.number), - })); - - return [...blockSources, ...reviewerSources]; -} - -/** - * The context line, made drill-down-able: when a pull's context text has - * feedback behind it (an active dev/deploy block, an unstamped review), the - * text becomes a hover-preview trigger for who said what — the feedback - * itself one hover away, instead of a screenful of clicking through to - * GitHub. Falls back to the plain text (today's behavior) when there's - * nothing to show, same as SigPips falls back to plain pips with no - * signatures. - */ -export function FeedbackPopover({ pull, text }: { pull: DerivedPull; text: string }) { - const sources = feedbackSources(pull); - if (!sources.length) return {text}; - - return ( - ( - - )} - > - Feedback - {sources.map(s => ( -
- - - {s.login} - {s.stateWord} - - {ago(s.atEpoch)} ago - - - {s.body &&

{s.body}

} - - View on GitHub → - -
- ))} -
- ); -} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index e298280d..176c0f3c 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -1,7 +1,8 @@ -import { memo, useState } from 'react'; +import { memo, useState, type ReactNode } from 'react'; import type { DerivedPull } from '../model/status'; import { isIterating, lastPushEpoch } from '../model/status'; import { rowNote } from '../model/actions'; +import type { ParentRef } from '../model/stack'; import { ago, epoch, pullKey, shortRepo } from '../format'; import { setRepoPref, @@ -11,9 +12,9 @@ import { useSettings, } from '../settings'; import { ackPull, isFresh, refreshPull, snoozePull, usePulldasher } from '../store'; -import { AgeStamp, DiffSize, FreshTag, RepoRef, SigPips, StatusBadge, WeightMeter } from './bits'; +import { AgeStamp, DiffSize, FreshTag, RepoRef, SigPips, WeightMeter } from './bits'; import { CardShell } from './Card'; -import { FeedbackPopover } from './FeedbackPopover'; +import { ContextPopover, StatusBadgeTrigger } from './StatePopover'; import { Popover } from './Popover'; export interface RowOptions { @@ -36,6 +37,12 @@ export interface RowOptions { /** toggle a weight bucket ('xs'..'xl' or 'unknown') in the session Weight * filter — the row-initiated twin of WeightFilter's own checkboxes */ onWeightToggle?: (w: string) => void; + /** whole-board parent lookup (model/stack.ts's buildParentLookup, memoized + * once in app.tsx): resolves a dependent pull's parent even when it's + * absent from the CURRENT list (a different lane, a muted repo, another + * lens' scope), so the 'stacked' flag can name it instead of just saying + * "based on ". */ + parentOf?: (p: DerivedPull) => ParentRef | null; } /** @@ -62,7 +69,7 @@ interface Flag { /** 'warn' = act on it (amber); 'note' = a neutral fact (muted) */ tone: 'warn' | 'note'; label: string; - detail: string; + detail: ReactNode; } /** @@ -70,8 +77,20 @@ interface Flag { * holds, in-flight CI, iterating, aging. They're not the status (the badge is) * and not your action (the note is), so they read as quiet colored labels, * not badges — amber only for the ones you act on, muted gray for plain facts. + * + * `depth` is the row's stack-nesting depth (model/stack.ts's groupIntoTree): + * depth > 0 means the parent is already visible right above this row, so the + * 'stacked' flag would be redundant — geometry already says it. At depth 0, + * `orphanParent` (the whole-board lookup, when it resolves) upgrades the + * flag to name the parent instead of only describing the base ref. */ -function rowFlags(pull: DerivedPull, showIterating: boolean, aging: boolean): Flag[] { +function rowFlags( + pull: DerivedPull, + showIterating: boolean, + aging: boolean, + depth: number, + orphanParent: ParentRef | null +): Flag[] { const p = pull; const flags: Flag[] = []; if (p.conflict && p.status !== 'unmergeable') @@ -116,13 +135,36 @@ function rowFlags(pull: DerivedPull, showIterating: boolean, aging: boolean): Fl detail: `Open ${p.ageDays} days without full CR (${p.crHave} of ${p.data.status.cr_req}).${quiet}`, }); } - if (p.dependent && p.status !== 'unmergeable') - flags.push({ - key: 'stacked', - tone: 'note', - label: 'stacked', - detail: `Based on ${p.data.base.ref}, not the main branch; it lands with its parent.`, - }); + if (p.dependent && p.status !== 'unmergeable' && depth === 0) { + flags.push( + orphanParent + ? { + key: 'stacked', + tone: 'note', + label: `stacked on #${orphanParent.number}`, + detail: ( + <> + Based on {p.data.base.ref}; lands with its parent,{' '} +
+ “{orphanParent.title}” #{orphanParent.number} + + . + + ), + } + : { + key: 'stacked', + tone: 'note', + label: 'stacked', + detail: `Based on ${p.data.base.ref}, not the main branch; it lands with its parent.`, + } + ); + } if (p.mergeUnknown && p.status === 'ready') flags.push({ key: 'merge', @@ -505,7 +547,18 @@ function MetricRail({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { ); } -function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { +function RowImpl({ + pull, + opts, + depth = 0, +}: { + pull: DerivedPull; + opts: RowOptions; + /** stack-nesting depth from model/stack.ts's groupIntoTree (0 = top-level + * or not rendered through a stack-aware list). Per-row data, not an + * option — it varies row to row within the same list. */ + depth?: number; +}) { const d = pull.data; const key = pullKey(d); const fresh = freshKind(pull, opts); @@ -518,6 +571,9 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { // avatar, so the row itself says "you follow this person" without a // trip to the kebab menu const starredAuthor = useSettings().starredPeople.includes(d.user.login); + // only worth asking the whole-board lookup when this row is stacked but + // rendering flat (its parent isn't visible right above it already) + const orphanParent = pull.dependent && depth === 0 ? (opts.parentOf?.(pull) ?? null) : null; return ( ackPull(key)} compact={opts.compact} + depth={depth} className={`${flashOnce(key, !!fresh) ? 'row-fresh' : ''} transition-[background-color] duration-150 ease-out motion-reduce:transition-none`} avatarBadge={ starredAuthor && ( @@ -544,17 +601,26 @@ function RowImpl({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { // instead of clipping, so no chip ever costs the title its text <> {fresh && } - {/* the status badge always shows the state; when there's also a - viewer move, the do-pill rides right after it, so "what's + {/* the status badge always shows the state, and now doubles as + the state popover's trigger; when there's also a viewer + move, the do-pill rides right after it, so "what's happening" and "what to do" both stay visible at once */} - + {note.action && {note.action}} {pull.sizeKnown && ( )} - {note.context && } - + {note.context && } + } rail={} @@ -571,6 +637,7 @@ export const Row = memo( RowImpl, (a, b) => a.pull === b.pull && + a.depth === b.depth && a.opts.aging === b.opts.aging && a.opts.compact === b.opts.compact && a.opts.me === b.opts.me && @@ -579,5 +646,6 @@ export const Row = memo( a.opts.onPerson === b.opts.onPerson && a.opts.ageWarnDays === b.opts.ageWarnDays && a.opts.ageRotDays === b.opts.ageRotDays && - a.opts.onWeightToggle === b.opts.onWeightToggle + a.opts.onWeightToggle === b.opts.onWeightToggle && + a.opts.parentOf === b.opts.parentOf ); diff --git a/frontend-v2/src/components/StatePopover.tsx b/frontend-v2/src/components/StatePopover.tsx new file mode 100644 index 00000000..6e08fd2a --- /dev/null +++ b/frontend-v2/src/components/StatePopover.tsx @@ -0,0 +1,302 @@ +import type { DerivedPull } from '../model/status'; +import type { RowNote } from '../model/actions'; +import { rowNote } from '../model/actions'; +import type { PullData } from '../types'; +import { ago, closedEpoch, epoch, githubUrl, signatureUrl } from '../format'; +import { Avatar, ClosedBadge, STATUS_LABEL, StatusBadge } from './bits'; +import { Popover } from './Popover'; + +interface FeedbackSource { + key: string; + login: string; + /** "blocked" / "changes requested" / "commented" */ + stateWord: string; + atEpoch: number; + body?: string; + url: string; +} + +function reviewStateWord(state: string): string { + return state === 'CHANGES_REQUESTED' ? 'changes requested' : 'commented'; +} + +/** CHANGES_REQUESTED sorts ahead of COMMENTED/DISMISSED — the harsher verdict + * is the more actionable one to see first. */ +function reviewRank(state: string): number { + return state === 'CHANGES_REQUESTED' ? 0 : 1; +} + +/** + * Every feedback source behind a pull's context line, in the order they + * should read: active dev/deploy blocks first (the reason the pull can't + * move at all), then unstamped reviewers (changes-requested before mere + * comments). Empty when the pull has nothing feedback-shaped to show. + */ +function feedbackSources(p: DerivedPull): FeedbackSource[] { + const d = p.data; + + const blockSources: FeedbackSource[] = [...d.status.dev_block, ...d.status.deploy_block] + .filter(s => s.data.active) + .map(s => ({ + key: `block-${s.data.comment_id}`, + login: s.data.user.login, + stateWord: 'blocked', + atEpoch: epoch(s.data.created_at), + url: signatureUrl(s), + })); + + const reviewerSources: FeedbackSource[] = [...(d.status.unstamped_reviewers ?? [])] + .sort((a, b) => reviewRank(a.state) - reviewRank(b.state)) + .map((r, i) => ({ + key: `review-${r.login}-${i}`, + login: r.login, + stateWord: reviewStateWord(r.state), + atEpoch: r.date, + body: r.body, + url: + r.review_id != null + ? `${githubUrl(d.repo, d.number)}#pullrequestreview-${r.review_id}` + : githubUrl(d.repo, d.number), + })); + + return [...blockSources, ...reviewerSources]; +} + +/** The one-sentence gloss for a doOnly note (action set, context null) — + * rowNote's context carries the explanation for every other case, but these + * four never set one, so the popover restates the action in plain words + * instead of leaving Section 1 blank. */ +const ACTION_EXPLANATION: Record = { + 'Finish the draft': 'still being drafted — not ready for review yet.', + 'Merge it': 'fully signed off and green — go ahead and merge.', + 'Finish QA': "you're already testing it — finish up and stamp.", + 'Re-QA': 'a push invalidated your QA stamp.', +}; + +function stateExplanation(pull: DerivedPull, note: RowNote): string { + if (note.context) return note.context; + if (note.action && ACTION_EXPLANATION[note.action]) return ACTION_EXPLANATION[note.action]; + return `${STATUS_LABEL[pull.status].toLowerCase()}.`; +} + +/** Section 1: what this pull's badge means, restated in a sentence, plus the + * viewer's own move when they have one. */ +function StateSection({ pull, me }: { pull: DerivedPull; me: string }) { + const note = rowNote(pull, me); + return ( +
+

{STATUS_LABEL[pull.status]}

+

{stateExplanation(pull, note)}

+ {note.action &&

your move: {note.action}

} +
+ ); +} + +/** Section 2: the sign-off/CI facts every row's rail hints at, spelled out. */ +function FactsSection({ pull }: { pull: DerivedPull }) { + const d = pull.data; + const crReq = d.status.cr_req; + const qaReq = d.status.qa_req; + const ciWord = + pull.ci === 'failing' + ? 'red' + : pull.ci === 'pending' + ? 'running' + : pull.ci === 'success' + ? 'green' + : 'none required'; + return ( +
+

+ opened {ago(epoch(d.created_at))} ago by{' '} + {d.user.login} · updated{' '} + {ago(epoch(d.updated_at))} ago +

+

+ CR {pull.crHave} of {crReq} + {pull.crBy.length > 0 && <> · {pull.crBy.join(', ')}} + {pull.recrBy.length > 0 && ( + · stale: {pull.recrBy.join(', ')} + )} +

+

+ QA {pull.qaHave} of {qaReq} + {pull.qaBy.length > 0 && <> · {pull.qaBy.join(', ')}} + {pull.qaingLogin && <> · {pull.qaingLogin} testing} + {pull.reqaBy.length > 0 && ( + · stale: {pull.reqaBy.join(', ')} + )} +

+

+ CI: {ciWord} + {pull.ci === 'failing' && pull.ciFailing.length > 0 && ( + <> · {pull.ciFailing.join(', ')} + )} +

+
+ ); +} + +/** Section 3: the existing feedback excerpts, unchanged — just relocated + * into the universal popover instead of their own mini one. */ +function FeedbackSection({ pull }: { pull: DerivedPull }) { + const sources = feedbackSources(pull); + if (!sources.length) return null; + return ( +
+ {sources.map(s => ( +
+ + + {s.login} + {s.stateWord} + + {ago(s.atEpoch)} ago + + + {s.body &&

{s.body}

} + + View on GitHub → + +
+ ))} +
+ ); +} + +function StatePopoverBody({ pull, me }: { pull: DerivedPull; me: string }) { + return ( + <> + + + + + View PR on GitHub → + + + ); +} + +// pd-raise: every trigger here sits inside a row whose title link stretches +// an invisible click-through layer over the whole card (see styles.css) — +// without it, none of these would be clickable at all. +const TRIGGER_HINT = + 'pd-raise border-0 bg-transparent p-0 hover:underline hover:decoration-dotted hover:underline-offset-2'; + +/** + * Every open row's badge doubles as a hover-pin popover for the pull's full + * state: the status restated, the sign-off/CI facts the rail only hints at, + * and (when present) the feedback behind it. One entry point every card + * gets, badge or no other context text. + */ +export function StatusBadgeTrigger({ pull, me }: { pull: DerivedPull; me: string }) { + return ( + ( + + )} + > + + + ); +} + +/** + * The context line's own door into the same popover: when a pull's context + * text has feedback behind it (an active dev/deploy block, an unstamped + * review), the text becomes a second trigger for the same full-state content + * — instead of a screenful of clicking through to GitHub. Falls back to + * plain text when there's nothing to show, same as SigPips falls back to + * plain pips with no signatures. + */ +export function ContextPopover({ + pull, + me, + text, +}: { + pull: DerivedPull; + me: string; + text: string; +}) { + const hasFeedback = feedbackSources(pull).length > 0; + if (!hasFeedback) return {text}; + + return ( + ( + + )} + > + + + ); +} + +/** + * The closed-row receipt: same trigger pattern, a much shorter panel — a + * merged/closed pull has no state to restate, no facts left to owe anyone, + * just when it landed and a link to the record. + */ +export function ClosedBadgeTrigger({ pull }: { pull: PullData }) { + const merged = !!pull.merged_at; + const closedAt = closedEpoch(pull); + return ( + ( + + )} + > +

+ {merged ? 'merged' : 'closed'} {ago(closedAt)} ago by{' '} + {pull.user.login} +

+ + View PR on GitHub → + +
+ ); +} From 01262cb1486ab2c4dac6a3476e9c8170e6742ac8 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 01:15:44 -0700 Subject: [PATCH 142/381] Broadcast label changes on their own authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A labeled/unlabeled webhook wrote the label row but never marked the pull dirty itself — clients only heard about it because the pull_request handler happens to call updatePull unconditionally afterward, which marks dirty as a side effect. Progressive CR-weight updates (the size labels from ifixit#63258) depend on that broadcast, so anchor it where the label change happens: handleLabelEvents marks the pull dirty after its own write. An audit traced the whole label-to-weight chain and this was its one fragile link. Co-Authored-By: Claude Opus 4.8 --- controllers/githubHooks.js | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/controllers/githubHooks.js b/controllers/githubHooks.js index c11ddfb3..82e08e0c 100644 --- a/controllers/githubHooks.js +++ b/controllers/githubHooks.js @@ -8,6 +8,7 @@ import Review from "../models/review.js"; import Status from "../models/status.js"; import Label from "../models/label.js"; import refresh from "../lib/refresh.js"; +import queue from "../lib/pull-queue.js"; import getLogin from "../lib/get-user-login.js"; import utils from "../lib/utils.js"; import dbManager from "../lib/db-manager.js"; @@ -242,23 +243,37 @@ function handleLabelEvents(body) { switch (body.action) { case "labeled": hooksDebug("Added label: %s", body.label.name); - return dbManager.insertLabel( - new Label( - body.label, - object.number, - body.repository.full_name, - getLogin(body.sender), - object.updated_at + return dbManager + .insertLabel( + new Label( + body.label, + object.number, + body.repository.full_name, + getLogin(body.sender), + object.updated_at + ) ) - ); + .then(markDirtyIfPull); case "unlabeled": hooksDebug("Removed label: %s", body.label.name); - return dbManager.deleteLabel( - new Label(body.label, object.number, body.repository.full_name) - ); + return dbManager + .deleteLabel( + new Label(body.label, object.number, body.repository.full_name) + ) + .then(markDirtyIfPull); } return Promise.resolve(); + + // A label change must broadcast on its own: the weight labels drive the + // frontend's CR-weight display, and the only other thing that notifies + // clients after a label event is an incidental updatePull call in the + // pull_request handler — an easy thing for a refactor to scope away. + function markDirtyIfPull() { + if (body.pull_request) { + queue.markPullAsDirty(body.repository.full_name, object.number); + } + } } function refreshPullOrIssue(responseBody) { From 1db384feb7dd794c43371a24a11587f4bfb872f7 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 01:25:46 -0700 Subject: [PATCH 143/381] Say "Lift your block" when the author blocked themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An author who dev-blocked their own PR read "Address feedback · from you" — technically true, useless as a nudge. When you're the only blocker the action is lifting your own block, so say that; when others blocked too, address their feedback and name only them. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/actions.test.ts | 14 ++++++++++++++ frontend-v2/src/model/actions.ts | 14 +++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/frontend-v2/src/model/actions.test.ts b/frontend-v2/src/model/actions.test.ts index febcd8da..91e803bc 100644 --- a/frontend-v2/src/model/actions.test.ts +++ b/frontend-v2/src/model/actions.test.ts @@ -119,6 +119,20 @@ describe('rowNote — the author matrix', () => { }); }); + it('dev_block: blocking your own PR is a lift, not an "address feedback from you"', () => { + expect(note({ status: 'dev_block', devBlockedBy: [me] }, me)).toEqual({ + action: 'Lift your block', + context: null, + }); + }); + + it('dev_block: your own block plus another blocker still names only the other', () => { + expect(note({ status: 'dev_block', devBlockedBy: [me, 'carol'] }, me)).toEqual({ + action: 'Address feedback', + context: 'from carol', + }); + }); + it('changesRequestedBy beats the plain needs_recr wait', () => { expect( note({ status: 'needs_recr', changesRequestedBy: ['carol'], recrBy: ['dave'] }, me) diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index e5630794..53951baf 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -91,9 +91,17 @@ function authorNote(p: DerivedPull, me: string): RowNote { if (p.status === 'ci_red') return { action: 'Fix CI', context: p.ciFailing.length ? p.ciFailing.join(', ') : null }; // a dev block is feedback waiting on YOU — v1 lore said "ask them to lift - // it", which misroutes the most common author action - if (p.status === 'dev_block') - return { action: 'Address feedback', context: `from ${who(p.devBlockedBy)}` }; + // it", which misroutes the most common author action. But when you're the + // only blocker, "from you" reads as nonsense ("Address feedback · from + // you") — the real move is lifting your own block, not answering it, so + // that case gets its own action. A mix of you and others still owes an + // answer to the others; who() drops you from that list so the context + // doesn't also claim you're waiting on yourself. + if (p.status === 'dev_block') { + const otherBlockers = p.devBlockedBy.filter(l => l !== me); + if (!otherBlockers.length) return doOnly('Lift your block'); + return { action: 'Address feedback', context: `from ${who(otherBlockers)}` }; + } if ((p.status === 'needs_cr' || p.status === 'needs_recr') && p.changesRequestedBy.length) return { action: 'Address feedback', From 7de990d635666eee3c021b608daa9750715f7eb2 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 01:25:47 -0700 Subject: [PATCH 144/381] Fix mobile nav overflow, dead banner, and the Review-tab snap-back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three header-area bugs from mobile QA: The lens nav had no scroll escape: as a flex item with default min-width:auto its six tabs set a min-content width the row couldn't shrink below, so at 375px the whole page grew wider than the viewport and clipped ("My work 5" also wrapped mid-label). The nav now scrolls horizontally inside its own box — min-w-0, nowrap tabs, hidden scrollbar chrome — and the body never scrolls sideways. The "N merged or closed since your last look" banner was dead text. It's now a button (only when the current lens has a shipped fold with content): opens the fold by writing the same pd2.folds store a click would, smooth-scrolls to it, and pulses it with the existing fresh-flash keyframe — new information announced in the motion vocabulary the board already uses. Clicking Review snapped back to the configured default view: buildHash omitted the lens param against a hardcoded 'review' while readHash fell back to settings.defaultLens, so a lens-less hash meant different things to writer and reader. Both now share one defaultLensFallback(). Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 103 ++++++++++++++++++++++++---- frontend-v2/src/components/Lane.tsx | 17 +++++ frontend-v2/src/styles.css | 18 +++++ 3 files changed, 124 insertions(+), 14 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 8d1de3f1..6c9d458e 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -21,6 +21,7 @@ import { getSettings, useSettings } from './settings'; import { useNotifications } from './notifications'; import { matchesQuery } from './model/query'; import { CRYO_KEY, isBotLogin, personHidden, repoHidden } from './model/visibility'; +import { foldDomId, openFold } from './components/Lane'; import { Legend } from './components/Legend'; import { RepoFilter } from './components/filters/RepoFilter'; import { PeopleFilter } from './components/filters/PeopleFilter'; @@ -75,6 +76,19 @@ interface HashState { drafts: 'mine' | 'all' | null; } +/** + * The lens a bare hash (no `lens=` param) resolves to — the single source of + * truth both readHash's fallback and buildHash's omission rule must agree on. + * buildHash used to compare against a hardcoded 'review' while this fallback + * tracked the user's configured default: with any other default, clicking + * Review wrote a lens-less hash, the hashchange listener re-read it via this + * same fallback, and the board snapped back to the configured default. + */ +function defaultLensFallback(): Lens { + const preferred = getSettings().defaultLens as Lens; + return LENSES.includes(preferred) ? preferred : ('review' as Lens); +} + function readHash(): HashState { const p = new URLSearchParams(location.hash.slice(1)); let lens = p.get('lens') as Lens | null; @@ -82,8 +96,7 @@ function readHash(): HashState { // old #lens=board links keep working if ((lens as string) === 'board') lens = 'classic'; // a bare URL (no lens param) opens the user's configured default view - const preferred = getSettings().defaultLens as Lens; - const fallback = LENSES.includes(preferred) ? preferred : ('review' as Lens); + const fallback = defaultLensFallback(); return { lens: lens && LENSES.includes(lens) ? lens : fallback, person: p.get('person'), @@ -103,7 +116,7 @@ function readHash(): HashState { function buildHash(s: HashState): string { const p = new URLSearchParams(); - if (s.lens !== 'review') p.set('lens', s.lens); + if (s.lens !== defaultLensFallback()) p.set('lens', s.lens); if (s.person) p.set('person', s.person); if (s.team) p.set('team', s.team); if (s.q) p.set('q', s.q); @@ -125,20 +138,38 @@ if (urlState.repos.length || urlState.authors.length) { } /** Full-width notice under the header: bad = red alert, warn = amber, brand = informational. */ -function Banner({ tone, children }: { tone: 'bad' | 'warn' | 'brand'; children: ReactNode }) { +function Banner({ + tone, + onClick, + ariaLabel, + children, +}: { + tone: 'bad' | 'warn' | 'brand'; + /** present only when the banner has somewhere useful to send the click — + * renders it as a real button instead of a static strip */ + onClick?: () => void; + ariaLabel?: string; + children: ReactNode; +}) { const inner: Record = { bad: 'border-bad bg-surface text-bad', warn: 'border-warn bg-surface', brand: 'notice-inner border-brand bg-brand-50 text-brand-700', }; + const surface = `flex w-full items-center gap-2 rounded-lg border px-3 py-[7px] text-left ${inner[tone]} ${ + onClick ? 'pressable hover:bg-brand-100' : '' + }`; return (
-
- {children} -
+ {onClick ? ( + + ) : ( +
+ {children} +
+ )}
); } @@ -292,6 +323,7 @@ export function App() { reveal, draftsMode, settings.draftsMode, + settings.defaultLens, ]); useEffect(() => { const onHash = () => { @@ -602,7 +634,7 @@ export function App() { type="button" aria-current={lens === id ? 'page' : undefined} onClick={() => setLens(id)} - className={`pressable rounded-lg border-0 px-3 py-2 text-sm font-medium ${ + className={`pressable shrink-0 rounded-lg border-0 px-3 py-2 text-sm font-medium whitespace-nowrap ${ lens === id ? 'bg-secondary text-ink' : 'bg-transparent text-ink-2 hover:text-brand' }`} > @@ -613,6 +645,38 @@ export function App() { ); + // The "recently shipped" fold only exists on Review and My work — the + // other four lenses have nowhere for the merged-count banner to jump to. + // Its count must match what that fold will actually render (scopedClosed + // for Review, self-authored closed for My work), not the raw org-wide + // mergedCount above, or the banner could promise a jump the fold can't + // deliver (count 0, fold not rendered at all). + const shippedFoldId = + lens === 'review' ? 'review:shipped' : lens === 'mine' ? 'mine:shipped' : null; + const shippedFoldCount = + lens === 'review' + ? scopedClosed.length + : lens === 'mine' + ? closed.filter(p => p.user.login === me).length + : 0; + const jumpToShipped = shippedFoldId + ? () => { + const id = shippedFoldId; + openFold(id); + // openFold's write lands via useSyncExternalStore, so the fold's + // `open` attribute (and the height its rows add) commits on the + // next paint, not synchronously in this handler — wait a frame so + // the scroll lands on the expanded fold, not the collapsed one. + requestAnimationFrame(() => { + const el = document.getElementById(foldDomId(id)); + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + el.classList.add('fold-flash'); + setTimeout(() => el.classList.remove('fold-flash'), 900); + }); + } + : undefined; + return ( <>
@@ -666,8 +730,15 @@ export function App() { extraBots={extraBots} />
-
-
diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index c5911b58..622dee19 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -368,6 +368,38 @@ export function Settings({ onChange={ageRotDays => set({ ageRotDays })} /> + + set({ claimLengthMins: Number(v) })} + /> + + + set({ claimWarnMins: Number(v) })} + /> + (); for (const p of pulls) { const key = pullKey(p.data); const c = claims[key]; - if (c && c.login === me && !hasStamp(p, me) && now - c.at > STALE_CLAIM_MS) { + if (c && c.login === me && !hasStamp(p, me) && now - c.at > warnMs) { staleClaims.set(key, p); } } diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index 571b14da..29e13c9a 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -63,6 +63,12 @@ export interface Settings { * these floats to the top of the review queue. Arbitrary strings, not a * known set — unlike repos/logins. */ codeRegions: string[]; + /** how long your review claims last before the server expires them, sent + * to the server as the ttlMs on claimReview */ + claimLengthMins: number; + /** when an unfinished claim of yours starts nagging you to finish or + * release it */ + claimWarnMins: number; } export const DEFAULT_SETTINGS: Settings = { @@ -85,6 +91,8 @@ export const DEFAULT_SETTINGS: Settings = { starredPeople: [], mutedPeople: [], codeRegions: [], + claimLengthMins: 240, + claimWarnMins: 120, }; const store = createPersistentStore('pd2.settings', DEFAULT_SETTINGS); diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts index 2598e9a6..1896086d 100644 --- a/frontend-v2/src/store.ts +++ b/frontend-v2/src/store.ts @@ -373,7 +373,7 @@ export function claimFor( * through the store like every other mutation instead of reaching into the * backend directly. */ export function claimReview(pull: Pick): void { - backend.claimReview(pull.repo, pull.number); + backend.claimReview(pull.repo, pull.number, getSettings().claimLengthMins * 60_000); } export function releaseReview(pull: Pick): void { backend.releaseReview(pull.repo, pull.number); diff --git a/frontend-v2/src/toasts.tsx b/frontend-v2/src/toasts.tsx index 77fb1998..f5453ae5 100644 --- a/frontend-v2/src/toasts.tsx +++ b/frontend-v2/src/toasts.tsx @@ -233,7 +233,14 @@ export function useToasts( useEffect(() => { const on = getSettings().cheers; const { toasts: fresh, next } = evaluateCheers( - { pulls, closed, me, claims, now: Date.now() }, + { + pulls, + closed, + me, + claims, + now: Date.now(), + claimWarnMs: getSettings().claimWarnMins * 60_000, + }, baseline.current ); // a streak toast on the session's first review — bumped against the From bf50002d0d5ab2d842534e141adce99963e50c3c Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 15:39:48 -0700 Subject: [PATCH 176/381] Highlight PRs GitHub asked you to review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assignee/reviewer merge (#476) now ships requested_reviewers on the wire, but v2 ignored it. A GitHub review request is the most concrete "review this" signal the board has — more authoritative than our own turn rotation, which only guesses whose turn a starved pull is. Wire it through so an explicit request supersedes the guess rather than competing with it: - turnFor stays silent on any pull carrying a request, so the board never rotates a name onto a PR GitHub already routed to someone. - the row note reads "Review it · requested from you" (or names the other requested reviewer for a bystander), and a claim still wins (you're already on it). - the row shows a filled-brand "review requested" chip while it's still your move (unstamped, not your own claim). - Review opens a "Requested of you" lane, and the cheers evaluator fires a one-shot "Review requested" toast when a request lands. Also reorder the Review lanes so personal commitments and the most time-decayed work lead: You're reviewing and Requested of you sit up top, and Aging rises above the general Review queue. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/backend/dummy.ts | 13 +++++++ frontend-v2/src/components/Row.tsx | 24 +++++++++++++ frontend-v2/src/model/actions.ts | 37 +++++++++++++++---- frontend-v2/src/model/cheers.test.ts | 31 ++++++++++++++++ frontend-v2/src/model/cheers.ts | 45 +++++++++++++++++++++++ frontend-v2/src/model/reviewers.test.ts | 34 ++++++++++++++++++ frontend-v2/src/model/reviewers.ts | 30 ++++++++++++++++ frontend-v2/src/model/rotation.ts | 5 +++ frontend-v2/src/types.ts | 7 ++++ frontend-v2/src/views/Review.tsx | 47 ++++++++++++++++++------- 10 files changed, 254 insertions(+), 19 deletions(-) create mode 100644 frontend-v2/src/model/reviewers.test.ts create mode 100644 frontend-v2/src/model/reviewers.ts diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts index dedff519..e858591b 100644 --- a/frontend-v2/src/backend/dummy.ts +++ b/frontend-v2/src/backend/dummy.ts @@ -134,6 +134,15 @@ function participantsFor(pull: PullData, i: number): string[] { const CHAIN_INDEXES = [10, 11, 12] as const; // parent, child, grandchild const FORK_INDEXES = [13, 14, 15] as const; // parent, child, child +// `requested_reviewers`/`assignees` postdate this fixture, so a few fixed pulls +// carry a review request aimed at the dummy viewer (so the "Requested of you" +// lane and the row's "review requested" chip demo) and a couple of assignees. +// Deterministic indexes, no randomness — same spirit as the rest of this file. +// The model drops a request aimed at a PR's own author, so an index that lands +// on the viewer's own pull just shows nothing, harmlessly. +const REVIEW_REQUEST_INDEXES = new Set([1, 3, 4, 7, 9]); +const ASSIGNEE_INDEXES = new Set([6, 12]); + function withSyntheticStacks(pulls: PullData[]): PullData[] { const out = [...pulls]; const rebase = (i: number, baseRef: string) => { @@ -167,6 +176,10 @@ function redate(pull: PullData, i: number): PullData { participants: participantsFor(pull, i), additions, deletions, + requested_reviewers: REVIEW_REQUEST_INDEXES.has(i) + ? [dummyUser()] + : (pull.requested_reviewers ?? []), + assignees: ASSIGNEE_INDEXES.has(i) ? [dummyUser()] : (pull.assignees ?? []), }; } diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 70c5aabc..63d8b67d 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -3,6 +3,7 @@ import type { DerivedPull } from '../model/status'; import { isIterating, lastPushEpoch } from '../model/status'; import { rowNote } from '../model/actions'; import { turnFor } from '../model/rotation'; +import { requestedReviewers, reviewRequestedFrom } from '../model/reviewers'; import { matchedRegions } from '../model/regions'; import type { ParentRef } from '../model/stack'; import { ago, epoch, pullKey, rowDomId, shortRepo } from '../format'; @@ -709,6 +710,14 @@ function RowImpl({ const starredAuthor = settings.starredPeople.includes(d.user.login); // which of your code regions this pull matched (why it floated to the top) const regions = matchedRegions(pull, settings.codeRegions); + // GitHub explicitly asked you to review this — the strongest "review this" + // signal there is. Shown only while it's still your move: not once you've + // stamped, and not when it's already your claim (the lit hand says that). + const requestedOfMe = + (pull.status === 'needs_cr' || pull.status === 'needs_recr') && + reviewRequestedFrom(pull, opts.me) && + !pull.crBy.includes(opts.me) && + claim?.login !== opts.me; // only worth asking the whole-board lookup when this row is stacked but // rendering flat (its parent isn't visible right above it already) const orphanParent = pull.dependent && depth === 0 ? (opts.parentOf?.(pull) ?? null) : null; @@ -752,6 +761,21 @@ function RowImpl({ poolSize={poolSize} /> {note.action && {note.action}} + {requestedOfMe && ( + 1 + ? ` (also ${requestedReviewers(pull) + .filter(l => l !== opts.me) + .join(', ')})` + : '' + }`} + className="chip-in inline-flex flex-none items-center gap-1 rounded bg-brand px-1.5 py-0.5 text-[11px] leading-none font-medium text-surface" + > + + review requested + + )} {regions.length > 0 && ( l !== me); if (p.recrBy.includes(me)) return { @@ -232,17 +248,26 @@ function reviewerNote( waitOnly(`waiting on ${who(p.recrBy)}${pushed}`), me, extra?.claim, - extra?.turn + extra?.turn, + requestedFromMe ); if (p.status === 'needs_cr') { const context = p.starved ? `unreviewed ${p.ageDays}d` : p.crHave > 0 ? `${p.crHave} of ${crReq} in` - : p.engagedNoStamp.length - ? `${who(p.engagedNoStamp)} looking` - : null; - return withCoordination({ action: 'Review it', context }, me, extra?.claim, extra?.turn); + : requestedOthers.length + ? `requested from ${who(requestedOthers)}` + : p.engagedNoStamp.length + ? `${who(p.engagedNoStamp)} looking` + : null; + return withCoordination( + { action: 'Review it', context }, + me, + extra?.claim, + extra?.turn, + requestedFromMe + ); } if (p.status === 'needs_qa') { if (p.qaBy.includes(me)) return waitOnly(`you've QA'd · ${p.qaHave} of ${qaReq}`); diff --git a/frontend-v2/src/model/cheers.test.ts b/frontend-v2/src/model/cheers.test.ts index 98abf684..0149085e 100644 --- a/frontend-v2/src/model/cheers.test.ts +++ b/frontend-v2/src/model/cheers.test.ts @@ -72,6 +72,7 @@ function sig(o: Partial & { queue?: number; pulls?: DerivedPull[] } = { myCount: o.myCount ?? 0, peerBelow: o.peerBelow ?? null, staleClaims: o.staleClaims ?? new Map(), + requestedOfMe: o.requestedOfMe ?? new Map(), }; } @@ -503,6 +504,36 @@ describe('diffCheers — stale claim', () => { }); }); +describe('diffCheers — review requested', () => { + it('fires once when GitHub newly requests your review, then stays quiet', () => { + const p = pull('org/a', 7, { author: 'alice' }); + const req = new Map([[pullKey(p.data), p]]); + const base = primed(sig()); // primed with no requests + const first = diffCheers(sig({ requestedOfMe: req, pulls: [p] }), 'me', base); + expect(first.toasts.some(t => t.dedupeKey === `req:${pullKey(p.data)}`)).toBe(true); + expect(first.toasts.some(t => t.title === 'Review requested')).toBe(true); + // same request next tick: silent + const second = diffCheers(sig({ requestedOfMe: req, pulls: [p] }), 'me', first.next); + expect(second.toasts.some(t => t.dedupeKey === `req:${pullKey(p.data)}`)).toBe(false); + // request dropped then re-added: nags again + const cleared = diffCheers(sig({ pulls: [p] }), 'me', second.next); + const again = diffCheers(sig({ requestedOfMe: req, pulls: [p] }), 'me', cleared.next); + expect(again.toasts.some(t => t.dedupeKey === `req:${pullKey(p.data)}`)).toBe(true); + }); + + it('primes an existing request silently on the first tick', () => { + const p = pull('org/a', 7, { author: 'alice' }); + const req = new Map([[pullKey(p.data), p]]); + const { toasts, next } = diffCheers( + sig({ requestedOfMe: req, pulls: [p] }), + 'me', + EMPTY_BASELINE + ); + expect(toasts.some(t => t.dedupeKey?.startsWith('req:'))).toBe(false); + expect(next.requestedSeen.has(pullKey(p.data))).toBe(true); + }); +}); + describe('diffCheers — MAX_PER_TICK', () => { it('keeps the highest-priority toasts when a tick overflows the cap', () => { const base = primed(sig({ review: 5, restampKeys: new Set(), quickWinCount: 0 })); diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index 703dd19d..81d13f99 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -3,6 +3,7 @@ import type { PullData } from '../types'; import { actionState } from './actions'; import { dealOne } from './deal'; import { reviewerRanks } from './leaderboard'; +import { reviewRequestedFrom } from './reviewers'; import { buildReviewerPools, turnFor } from './rotation'; import { crSort } from './sort'; import type { DerivedPull } from './status'; @@ -57,6 +58,9 @@ export interface CheerBaseline { myRank: number; /** you were the #1 reviewer on the board last tick */ wasTop: boolean; + /** review requests already toasted, so each fires once per pull; rebuilt + * from the current tick so a dropped-then-re-requested review nags again */ + requestedSeen: ReadonlySet; /** you had at least one reviewable pull last tick */ hadBacklog: boolean; /** per your-own open PR, last tick's state, for author-side edges */ @@ -84,6 +88,7 @@ export const EMPTY_BASELINE: CheerBaseline = { favorsSeen: new Set(), myRank: 0, wasTop: false, + requestedSeen: new Set(), hadBacklog: false, authorPrs: new Map(), boardQueue: 0, @@ -222,6 +227,9 @@ export interface Signals { peerBelow: string | null; /** pulls you hold a stale (2h+), still-unreviewed claim on */ staleClaims: Map; + /** pulls GitHub has asked YOU to review (and you haven't yet), for the + * review-requested toast — the most direct "review this" the board carries */ + requestedOfMe: Map; } export function readSignals(input: CheerInput): Signals { @@ -309,6 +317,22 @@ export function readSignals(input: CheerInput): Signals { } } + // GitHub asked you to review these and you haven't stamped or claimed them — + // a direct request, distinct from the rotation's guess (which stays silent + // on requested pulls). Drives the review-requested toast. + const requestedOfMe = new Map(); + for (const p of pulls) { + const key = pullKey(p.data); + if ( + (p.status === 'needs_cr' || p.status === 'needs_recr') && + reviewRequestedFrom(p, me) && + !p.crBy.includes(me) && + claims[key]?.login !== me + ) { + requestedOfMe.set(key, p); + } + } + const ranks = reviewerRanks(pulls, closed); const mine = ranks.get(me); const myRank = mine?.rank ?? 0; @@ -342,6 +366,7 @@ export function readSignals(input: CheerInput): Signals { myCount, peerBelow, staleClaims, + requestedOfMe, }; } @@ -369,6 +394,7 @@ const PRIORITY_ORDER = [ 'climbing', 'stamp-landed', 'pr-first-review', + 'review-requested', 'your-turn', 're-stamp-owed', 'claim-stale', @@ -420,6 +446,7 @@ export function diffCheers( favorsSeen: new Set(), myRank: sig.myRank, wasTop: sig.myRank === 1, + requestedSeen: new Set(sig.requestedOfMe.keys()), hadBacklog: sig.backlog > 0, authorPrs: sig.authorPrs, boardQueue: sig.boardReviewable, @@ -501,6 +528,23 @@ export function diffCheers( } const seenTurns = new Set(sig.turns.keys()); + // review requested: GitHub asked you directly. Fires once per pull (rebuilt + // from the current tick, seenTurns-style, so a dropped-then-re-added request + // can nag again). Distinct from your-turn: this is an explicit ask, not the + // rotation's guess — which is why turnFor stays silent on requested pulls. + for (const [key, p] of sig.requestedOfMe) { + if (base.requestedSeen.has(key)) continue; + push('review-requested', { + tone: 'info', + icon: '✦', + title: 'Review requested', + body: `${p.data.user.login} asked you to review this.`, + pull: pullRef(p), + dedupeKey: `req:${key}`, + }); + } + const requestedSeen = new Set(sig.requestedOfMe.keys()); + // start-here: the backlog re-arms after hitting zero (the prime-tick fire // is handled above, before this function's main body ever runs). if (!base.hadBacklog && sig.backlog > 0 && sig.bestStart) { @@ -691,6 +735,7 @@ export function diffCheers( favorsSeen, myRank: sig.myRank, wasTop: sig.myRank === 1, + requestedSeen, hadBacklog: sig.backlog > 0, authorPrs: sig.authorPrs, boardQueue: sig.boardReviewable, diff --git a/frontend-v2/src/model/reviewers.test.ts b/frontend-v2/src/model/reviewers.test.ts new file mode 100644 index 00000000..57c69943 --- /dev/null +++ b/frontend-v2/src/model/reviewers.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { hasReviewRequest, requestedReviewers, reviewRequestedFrom } from './reviewers'; +import type { DerivedPull } from './status'; + +function pull(author: string, requested: string[] | undefined): DerivedPull { + return { + data: { repo: 'org/a', number: 1, user: { login: author }, requested_reviewers: requested }, + } as unknown as DerivedPull; +} + +describe('requestedReviewers', () => { + it('returns the requested logins, dropping the author', () => { + expect(requestedReviewers(pull('alice', ['bob', 'alice', 'carol']))).toEqual([ + 'bob', + 'carol', + ]); + }); + + it('is empty when the field is absent (older servers)', () => { + expect(requestedReviewers(pull('alice', undefined))).toEqual([]); + expect(hasReviewRequest(pull('alice', undefined))).toBe(false); + }); +}); + +describe('reviewRequestedFrom', () => { + it('is true only when the viewer is in the request list', () => { + expect(reviewRequestedFrom(pull('alice', ['bob']), 'bob')).toBe(true); + expect(reviewRequestedFrom(pull('alice', ['bob']), 'carol')).toBe(false); + }); + + it('never treats the author as requested from themselves', () => { + expect(reviewRequestedFrom(pull('alice', ['alice']), 'alice')).toBe(false); + }); +}); diff --git a/frontend-v2/src/model/reviewers.ts b/frontend-v2/src/model/reviewers.ts new file mode 100644 index 00000000..c10894d9 --- /dev/null +++ b/frontend-v2/src/model/reviewers.ts @@ -0,0 +1,30 @@ +import type { DerivedPull } from './status'; + +/** + * GitHub review requests. A `requested_reviewers` entry is GitHub's explicit, + * authoritative "please review this" — a stronger signal than our own turn + * rotation (model/rotation.ts), which is only a deterministic guess for + * starved pulls with nobody assigned. So an explicit request supersedes the + * rotation: turnFor stays silent whenever a pull carries one, and the note / + * row highlight speak for the request instead. + * + * Kept viewer-agnostic and side-effect-free like the rest of model/. The + * "is it mine to review" question is answered at call sites that know `me`. + */ + +/** logins GitHub has an open review request from, minus the author (GitHub + * never requests a review from the PR's own author, but guard anyway). */ +export function requestedReviewers(p: DerivedPull): string[] { + return (p.data.requested_reviewers ?? []).filter(login => login !== p.data.user.login); +} + +/** GitHub has explicitly asked `me` to review this pull. */ +export function reviewRequestedFrom(p: DerivedPull, me: string): boolean { + return requestedReviewers(p).includes(me); +} + +/** The pull carries any open GitHub review request at all — the signal + * turnFor checks to know it should defer to GitHub rather than rotate. */ +export function hasReviewRequest(p: DerivedPull): boolean { + return requestedReviewers(p).length > 0; +} diff --git a/frontend-v2/src/model/rotation.ts b/frontend-v2/src/model/rotation.ts index 81d5d623..15f3329f 100644 --- a/frontend-v2/src/model/rotation.ts +++ b/frontend-v2/src/model/rotation.ts @@ -1,3 +1,4 @@ +import { hasReviewRequest } from './reviewers'; import type { DerivedPull, Status } from './status'; import { isBotLogin } from './visibility'; @@ -58,6 +59,10 @@ const TURN_STATUSES: Status[] = ['needs_cr', 'needs_recr']; */ export function turnFor(p: DerivedPull, pools: ReadonlyMap): string | null { if (!p.starved || !TURN_STATUSES.includes(p.status)) return null; + // An explicit GitHub review request answers "whose turn" authoritatively — + // don't also rotate a name onto the pull, or the board would tell someone + // it's their turn on a PR GitHub already routed to a specific reviewer. + if (hasReviewRequest(p)) return null; const pool = pools.get(p.data.repo); if (!pool || !pool.length) return null; const author = p.data.user.login; diff --git a/frontend-v2/src/types.ts b/frontend-v2/src/types.ts index 00023562..5b448d20 100644 --- a/frontend-v2/src/types.ts +++ b/frontend-v2/src/types.ts @@ -75,6 +75,13 @@ export interface PullData { head: { ref: string; sha: string; repo: { owner: { login: string } } }; base: { ref: string }; user: { login: string }; + /** GitHub assignees (logins). Added in the assignee/reviewer merge; servers + * older than that field omit it, so treat a missing value as []. */ + assignees?: string[]; + /** logins GitHub has an open review request from. The authoritative "review + * this" signal — stronger than our heuristic turn rotation, which defers to + * it. Omitted by servers older than the field, so read as [] when absent. */ + requested_reviewers?: string[]; // the wire also sends top-level cr_req/qa_req twins, but status.cr_req/ // qa_req are the ones every consumer reads — typing one copy prevents // reading the wrong one diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 39b8c629..78507df3 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -5,6 +5,7 @@ import { crSort, starFirst } from '../model/sort'; import { matchesRegion } from '../model/regions'; import { groupIntoTree } from '../model/stack'; import { authorMove, reviewerMove } from '../model/actions'; +import { reviewRequestedFrom } from '../model/reviewers'; import { dealOne } from '../model/deal'; import { useSettings } from '../settings'; import { claimReview, isFresh, usePulldasher } from '../store'; @@ -236,6 +237,17 @@ export function Review({ .filter(p => isFresh(p.data, opts.lastSeen, opts.acked)) .sort((a, b) => Date.parse(b.data.updated_at) - Date.parse(a.data.updated_at)); + // GitHub asked you directly — the most concrete "review this" on the board, + // so it leads. Only while it's still your move (unstamped, review-stage). + const requestedOfYou = crSort( + others.filter( + p => + (p.status === 'needs_cr' || p.status === 'needs_recr') && + reviewRequestedFrom(p, me) && + !p.crBy.includes(me) + ) + ); + // PRs you've claimed — the coordination lane so a claim isn't just a hand // icon buried in a lower lane; it's your commitment, surfaced up top. const claimed = crSort(pulls.filter(p => opts.claims?.[pullKey(p.data)]?.login === me)); @@ -299,13 +311,15 @@ export function Review({ )} - + {requestedOfYou.length > 0 && ( + + )} {claimed.length > 0 && ( )} + {codeRegions.length > 0 && regionMatches.length > 0 && ( )} + } /> - Date: Mon, 20 Jul 2026 15:40:31 -0700 Subject: [PATCH 177/381] Hide the row kebab menu on desktop The kebab was pure redundancy on a pointer device: copy, snooze, re-fetch, and claim all live in the hover cluster, and repo/person star-and-mute live in Settings' repo manager and the People lens. It only earns its keep below 720px, where the hover cluster doesn't exist and it's the sole tap path to those actions. Hide it at >=720px. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Row.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 63d8b67d..800f1612 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -449,13 +449,13 @@ function StarGlyph({ on }: { on: boolean }) { } /** - * The same three actions for pointers that can't hover, plus the star/mute - * board actions that only live here (never on the desktop icon cluster — - * see RowActions). Below 720px the desktop cluster doesn't exist at all, so - * every row keeps a quiet, always-visible kebab that opens a tap-friendly - * labeled menu (the house click-to-open popover — no hover in the path); at - * and above 720px it now rides at the end of the hover cluster too, since - * star/mute have no other home there. + * The touch-only row menu: below 720px the hover cluster (RowActions) doesn't + * exist, so every row keeps a quiet, always-visible kebab that opens a + * tap-friendly labeled menu (the house click-to-open popover — no hover in the + * path). It's hidden at and above 720px: everything it offers has a desktop + * home already — copy/snooze/re-fetch/claim in the hover cluster, and repo/ + * person star/mute in Settings' repo manager and the People lens — so on a + * pointer device the kebab was pure redundancy. */ function RowActionsKebab({ pull, @@ -480,7 +480,7 @@ function RowActionsKebab({ Date: Mon, 20 Jul 2026 15:44:13 -0700 Subject: [PATCH 178/381] Strip gamification slop from the toast copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A voice pass against the iFixit house voice found the slop concentrated in the cheers layer: personified taunts ("the queue fears you"), performed intensity on a small fact ("Chain unbroken. Respect."), unearned collective cheer ("Nice work, team."), and time estimates the app can't back up ("5-minute job", "clear them in a coffee break"). Trim the praise rotations, cut the trailing cheerleading, and drop the day-streak toast outright — a Duolingo-style loss-aversion nag has no place in a code-review tool, and it was the single worst offender. That retires model/streak.ts and its persisted pd2.streak state. Everything else (Legend, Settings, the views, empty states) already reads in-voice and is left alone. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Settings.tsx | 2 +- frontend-v2/src/model/cheers.test.ts | 4 +- frontend-v2/src/model/cheers.ts | 16 ++--- frontend-v2/src/model/streak.test.ts | 61 ------------------- frontend-v2/src/model/streak.ts | 44 -------------- frontend-v2/src/toasts.tsx | 78 ++++--------------------- 6 files changed, 21 insertions(+), 184 deletions(-) delete mode 100644 frontend-v2/src/model/streak.test.ts delete mode 100644 frontend-v2/src/model/streak.ts diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 622dee19..fd68637a 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -434,7 +434,7 @@ export function Settings({ { it('falls back to a quick-win when unfamiliar and no reciprocity', () => { const target = pull('org/b', 1, { author: 'alice', weight: 'XS', sizeKnown: true }); - expect(startHereReason(target, [target], 'me')).toBe('Small one (XS) — 5-minute job'); + expect(startHereReason(target, [target], 'me')).toBe('Small one (XS) — quick'); }); it('falls back to urgency when nothing else applies', () => { @@ -335,7 +335,7 @@ describe('diffCheers — return-the-favor', () => { const debtors = [{ login: 'alice', pull: p, count: 2 }]; const first = diffCheers(sig({ debtors }), 'me', base); expect(first.toasts.some(t => t.dedupeKey === 'favor:alice')).toBe(true); - expect(first.toasts.some(t => t.body?.includes('2 of yours'))).toBe(true); + expect(first.toasts.some(t => t.body?.includes('2 of your PRs'))).toBe(true); const second = diffCheers(sig({ debtors }), 'me', first.next); expect(second.toasts.some(t => t.dedupeKey === 'favor:alice')).toBe(false); }); diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index 81d13f99..390afbff 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -127,8 +127,8 @@ const MAX_PER_TICK = 3; /** Quick-win pile size that's worth a heads-up. */ const QUICK_WIN_THRESHOLD = 3; -const STAMP_PRAISE = ['Nice one.', 'The team thanks you.', "Keep 'em coming.", 'Clean.']; -const MILESTONE_PRAISE = ["Somebody's on a roll.", 'The queue fears you.', 'Unstoppable.']; +const STAMP_PRAISE = ['Nice one.', "Keep 'em coming.", 'Clean.']; +const MILESTONE_PRAISE = ['Good pace.']; /** Deterministic rotation through a copy list — seeded, not random, so tests * are stable and two clients narrate the same board the same way. */ @@ -165,7 +165,7 @@ export function startHereReason(p: DerivedPull, pulls: DerivedPull[], me: string const familiar = pulls.some(o => o.data.repo === repo && hasStamp(o, me)); if (familiar) return `You know ${shortRepo(repo)} — less to load in`; const quickWin = p.sizeKnown && (p.weight === 'XS' || p.weight === 'S'); - if (quickWin) return `Small one (${p.weight}) — 5-minute job`; + if (quickWin) return `Small one (${p.weight}) — quick`; return `Waiting ${Math.max(1, Math.round(p.ageDays))}d, the oldest on your plate`; } @@ -506,7 +506,7 @@ export function diffCheers( tone: 'reward', icon: '🎉', title: 'Inbox zero', - body: "Nothing's waiting on you. Go build something.", + body: "Nothing's waiting on you.", celebrate: true, dedupeKey: 'inbox:zero', }); @@ -594,7 +594,7 @@ export function diffCheers( tone: 'info', icon: '⚡', title: `${sig.quickWinCount} quick reviews on the board`, - body: 'XS/S — clear them in a coffee break.', + body: 'XS/S — small ones, unclaimed.', pull: sig.quickWinPull ? pullRef(sig.quickWinPull) : undefined, dedupeKey: `quick:${sig.quickWinCount}`, }); @@ -610,8 +610,8 @@ export function diffCheers( push('return-the-favor', { tone: 'info', icon: '🤝', - title: `${login} has one up`, - body: `They reviewed ${count} of yours — return the favor.`, + title: `Return the favor to ${login}`, + body: `They've reviewed ${count} of your ${count === 1 ? 'PR' : 'PRs'}.`, pull: pullRef(p), dedupeKey: `favor:${login}`, }); @@ -702,7 +702,7 @@ export function diffCheers( tone: 'reward', icon: '🎊', title: "Board's clear", - body: 'Nothing waiting on anyone. Nice work, team.', + body: 'Nothing waiting on anyone.', celebrate: true, dedupeKey: 'board:clear', }); diff --git a/frontend-v2/src/model/streak.test.ts b/frontend-v2/src/model/streak.test.ts deleted file mode 100644 index 4b421f23..00000000 --- a/frontend-v2/src/model/streak.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { bumpStreak, NO_STREAK, type StreakState } from './streak'; - -describe('bumpStreak', () => { - it('is a no-op for a same-day repeat', () => { - const prev: StreakState = { lastDay: '2026-07-18', count: 2 }; - const { state, toasted, milestone } = bumpStreak(prev, '2026-07-18'); - expect(state).toBe(prev); - expect(toasted).toBe(false); - expect(milestone).toBe(false); - }); - - it('starts a streak of 1 on the very first review', () => { - const { state, toasted } = bumpStreak(NO_STREAK, '2026-07-18'); - expect(state).toEqual({ lastDay: '2026-07-18', count: 1 }); - expect(toasted).toBe(true); - }); - - it('extends the streak on a consecutive day', () => { - const prev: StreakState = { lastDay: '2026-07-18', count: 1 }; - const { state, toasted } = bumpStreak(prev, '2026-07-19'); - expect(state).toEqual({ lastDay: '2026-07-19', count: 2 }); - expect(toasted).toBe(true); - }); - - it('chains several consecutive days', () => { - let state: StreakState = NO_STREAK; - const days = ['2026-07-01', '2026-07-02', '2026-07-03', '2026-07-04']; - for (const day of days) { - state = bumpStreak(state, day).state; - } - expect(state.count).toBe(4); - }); - - it('resets to 1 after a gap', () => { - const prev: StreakState = { lastDay: '2026-07-10', count: 5 }; - const { state, toasted } = bumpStreak(prev, '2026-07-18'); - expect(state).toEqual({ lastDay: '2026-07-18', count: 1 }); - expect(toasted).toBe(true); - }); - - it('flags a milestone at 3 and 7 but not in between', () => { - let state: StreakState = NO_STREAK; - const results: boolean[] = []; - const days = ['2026-07-01', '2026-07-02', '2026-07-03', '2026-07-04', '2026-07-05']; - for (const day of days) { - const r = bumpStreak(state, day); - state = r.state; - results.push(r.milestone); - } - // days: count 1, 2, 3(milestone), 4, 5 - expect(results).toEqual([false, false, true, false, false]); - }); - - it('flags the 7-day milestone', () => { - let state: StreakState = { lastDay: '2026-07-11', count: 6 }; - const r = bumpStreak(state, '2026-07-12'); - expect(r.state.count).toBe(7); - expect(r.milestone).toBe(true); - }); -}); diff --git a/frontend-v2/src/model/streak.ts b/frontend-v2/src/model/streak.ts deleted file mode 100644 index 3252248b..00000000 --- a/frontend-v2/src/model/streak.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * A day-streak counter for the "you've reviewed N days running" reward. - * Pure: the caller (the toasts hook) owns the clock and localStorage — this - * module only knows how to fold today's date into yesterday's state, so the - * transition logic is unit-testable without faking `Date` or a browser. - */ - -export interface StreakState { - /** the last day a review landed, "YYYY-MM-DD"; '' before the first ever */ - lastDay: string; - count: number; -} - -export const NO_STREAK: StreakState = { lastDay: '', count: 0 }; - -/** Streak lengths worth celebrating. */ -export const STREAK_MILESTONES = [3, 7, 14, 30]; - -/** Whole days between two "YYYY-MM-DD" dates (both UTC midnight, so this is - * always an exact integer — no DST/timezone rounding to worry about). */ -function daysBetween(from: string, to: string): number { - return (Date.parse(to) - Date.parse(from)) / 86_400_000; -} - -/** - * Fold today's first review into the streak. Same-day repeats are a no-op - * (only the day's FIRST stamp advances the streak); a day exactly after the - * last one extends it; anything else (a gap, or the very first review ever) - * starts a fresh streak of 1. `toasted` tells the caller whether this call - * changed the streak at all (worth a toast); `milestone` narrows that to a - * round number worth a bigger toast. - */ -export function bumpStreak( - prev: StreakState, - todayISO: string -): { state: StreakState; toasted: boolean; milestone: boolean } { - if (todayISO === prev.lastDay) { - return { state: prev, toasted: false, milestone: false }; - } - const consecutive = prev.lastDay !== '' && daysBetween(prev.lastDay, todayISO) === 1; - const count = consecutive ? prev.count + 1 : 1; - const state: StreakState = { lastDay: todayISO, count }; - return { state, toasted: true, milestone: STREAK_MILESTONES.includes(count) }; -} diff --git a/frontend-v2/src/toasts.tsx b/frontend-v2/src/toasts.tsx index f5453ae5..8a709c31 100644 --- a/frontend-v2/src/toasts.tsx +++ b/frontend-v2/src/toasts.tsx @@ -7,7 +7,6 @@ import { evaluateCheers, } from './model/cheers'; import type { DerivedPull } from './model/status'; -import { bumpStreak, NO_STREAK, type StreakState } from './model/streak'; import type { Toast, ToastTone } from './model/toast'; import { getSettings } from './settings'; import type { PullData } from './types'; @@ -36,22 +35,22 @@ const SAMPLE_CHEERS: CheerToast[] = [ { tone: 'info', icon: '🤝', - title: 'alice has one up', - body: 'They reviewed 3 of yours — return the favor.', + title: 'Return the favor to alice', + body: "They've reviewed 3 of your PRs.", pull: { repo: 'org/repo', number: 90, title: 'Add a retry to the webhook sender' }, }, { tone: 'info', icon: '⚡', title: '4 quick reviews on the board', - body: 'XS/S — clear them in a coffee break.', + body: 'XS/S — small ones, unclaimed.', }, { - tone: 'reward', - icon: '🔥', - title: '5-day review streak', - body: 'Chain unbroken. Respect.', - celebrate: true, + tone: 'info', + icon: '✦', + title: 'Review requested', + body: 'alice asked you to review this.', + pull: { repo: 'org/repo', number: 91, title: 'Rework the offer-sync batch loop' }, }, { tone: 'reward', @@ -85,7 +84,7 @@ const SAMPLE_CHEERS: CheerToast[] = [ tone: 'reward', icon: '🎊', title: "Board's clear", - body: 'Nothing waiting on anyone. Nice work, team.', + body: 'Nothing waiting on anyone.', celebrate: true, }, { @@ -102,51 +101,6 @@ interface LiveToast extends Toast { leaving?: boolean; } -/** The review streak persists across reloads so it counts calendar days, not - * sittings — the one piece of per-user history we keep, and only client-side - * (the server has no per-user memory). Key mirrors the other pd2.* prefs. */ -const STREAK_KEY = 'pd2.streak'; - -function loadStreak(): StreakState { - try { - const raw = localStorage.getItem(STREAK_KEY); - if (!raw) return NO_STREAK; - const p = JSON.parse(raw); - return typeof p?.lastDay === 'string' && typeof p?.count === 'number' ? p : NO_STREAK; - } catch { - return NO_STREAK; - } -} - -function saveStreak(s: StreakState) { - try { - localStorage.setItem(STREAK_KEY, JSON.stringify(s)); - } catch { - // storage disabled — the streak just won't survive the reload - } -} - -/** Today as YYYY-MM-DD (local), the grain bumpStreak compares on. */ -function isoToday(): string { - const d = new Date(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${d.getFullYear()}-${m}-${day}`; -} - -const STREAK_LINES = ["Don't break the chain.", 'Keep it alive.', 'Nice rhythm.']; - -function streakToast(count: number, milestone: boolean): Toast { - return { - tone: 'reward', - icon: '🔥', - title: `${count}-day review streak`, - body: milestone ? 'Chain unbroken. Respect.' : STREAK_LINES[count % STREAK_LINES.length], - celebrate: milestone, - dedupeKey: `streak:${count}`, - }; -} - /** * The gamification watcher: diffs each board snapshot against the last (all the * transition logic lives in the pure model/cheers evaluator) and surfaces the @@ -172,8 +126,6 @@ export function useToasts( const timers = useRef>>(new Map()); const toastsRef = useRef([]); const firedKeys = useRef>(new Set()); - // your stamp count last tick, to catch the session's first review (0 → >0) - const prevStamps = useRef(0); useEffect(() => { toastsRef.current = toasts; @@ -243,16 +195,6 @@ export function useToasts( }, baseline.current ); - // a streak toast on the session's first review — bumped against the - // persisted last-review day so it tracks calendar days across reloads, - // and only celebrated at ≥2 (a 1-day "streak" isn't one yet) - const streakToasts: Toast[] = []; - if (prevStamps.current === 0 && next.sessionStamps > 0) { - const { state, toasted, milestone } = bumpStreak(loadStreak(), isoToday()); - saveStreak(state); - if (toasted && state.count > 1) streakToasts.push(streakToast(state.count, milestone)); - } - prevStamps.current = next.sessionStamps; baseline.current = next; // quick-wins is a batch, not one pull: swap its scroll-to-pull for the // filter action so clicking shows every small review, not just the first @@ -261,7 +203,7 @@ export function useToasts( ? { ...t, pull: undefined, onAct: onQuickWins } : t ); - if (on) push([...bound, ...streakToasts]); + if (on) push(bound); }, [pulls, closed, me, claims, push, onQuickWins]); // pre-built one-shot toasts from the caller (e.g. the shipped catch-up), From 4be8f24d46fcd7dc7dd6b8db5866f6ddb2e825e1 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 15:47:55 -0700 Subject: [PATCH 179/381] Add a setting for whether a PR card opens in a new tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every external PR link already opened in a new tab, which is right for a hub — the board should stay put behind you. Make that an explicit, per-browser choice: the whole-card title link now honors an "Open a PR in" setting (New tab by default, or This tab for anyone who'd rather navigate in place). Read non-reactively in PullTitleLink; the row that renders it already subscribes to settings, so a toggle re-renders it. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Settings.tsx | 14 ++++++++++++++ frontend-v2/src/components/bits.tsx | 9 +++++++-- frontend-v2/src/settings.ts | 4 ++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index fd68637a..57ff89d4 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -425,6 +425,20 @@ export function Settings({ onChange={v => set({ showCryo: v === 'show' })} /> + + set({ openPrsNewTab: v === 'new' })} + /> + diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 77e1409a..907144a7 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -2,6 +2,7 @@ import type { ButtonHTMLAttributes, KeyboardEvent as ReactKeyboardEvent } from ' import { ROT_DAYS, STARVE_DAYS, type Status, type Weight, weightRank } from '../model/status'; import type { Signature } from '../types'; import { ago, epoch, githubUrl, loginHue, shortRepo, signatureUrl } from '../format'; +import { getSettings } from '../settings'; import { Popover } from './Popover'; export const STATUS_LABEL: Record = { @@ -488,12 +489,16 @@ export function PullTitleLink({ /** cover the whole row as one click target */ stretch?: boolean; }) { + // The whole-card click honors your "open PRs in a new tab" setting so the + // hub stays put behind you (the default) or navigates in place if you'd + // rather. Read non-reactively: the row that renders this already subscribes + // to settings, so a toggle re-renders it and this picks up the new value. + const newTab = getSettings().openPrsNewTab; return ( diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index 29e13c9a..dc91370f 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -69,6 +69,9 @@ export interface Settings { /** when an unfinished claim of yours starts nagging you to finish or * release it */ claimWarnMins: number; + /** open a PR in a new tab when you click its card, so the board stays put + * behind you — this is a hub. Off opens it in the same tab. */ + openPrsNewTab: boolean; } export const DEFAULT_SETTINGS: Settings = { @@ -93,6 +96,7 @@ export const DEFAULT_SETTINGS: Settings = { codeRegions: [], claimLengthMins: 240, claimWarnMins: 120, + openPrsNewTab: true, }; const store = createPersistentStore('pd2.settings', DEFAULT_SETTINGS); From 7c9b7aa521b4c13d541aca6a449a90789e0983f4 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 15:52:35 -0700 Subject: [PATCH 180/381] Redesign Stats with line, donut, and two-series charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stats view was a wall of hand-rolled bar rows and bare numbers: several time-series were split across separate single-series cards a reader had to mentally overlay, two comparisons (give/take, load vs awaiting-CR) hid half their data as trailing text, and Debt and Friction had no visual encoding at all. Add self-contained SVG primitives (no chart dependency) — a multi-series LineChart with dual axis / area / band, a Donut, and split/paired bar rows — and rework the cards: - Time in review: one line chart of first-CR and merge latency, resampled to a shared weekly grain, replacing three trend cards. - Monthly volume: opened vs merged as two lines with the backlog gap shaded, replacing twin bars. - Shipping & review pulse: merges/day (bars) against stamps/day (line) on a dual axis, replacing two sparkline cards. - Give and take, author/repo load: two-part bars instead of a bar plus a trailing number. - Review debt and Friction: each count gets a scaled bar. - Effort mix: a weight-ramp donut beside the bars. Reorder the bands most-actionable first — the board right now, Flow, People, then Trends — so the current-state debt and friction lead instead of a multi-month retrospective. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/stats.test.ts | 12 +- frontend-v2/src/model/stats.ts | 7 +- frontend-v2/src/model/statsHistory.test.ts | 68 ++- frontend-v2/src/model/statsHistory.ts | 141 +++++ frontend-v2/src/views/Stats.tsx | 90 ++-- .../src/views/stats/AuthorLoadCard.tsx | 19 +- frontend-v2/src/views/stats/DebtCard.tsx | 11 +- frontend-v2/src/views/stats/EffortMixCard.tsx | 48 +- .../src/views/stats/FirstCrTrendCard.tsx | 40 -- frontend-v2/src/views/stats/FrictionCard.tsx | 39 +- .../src/views/stats/MergeAgeByDayCard.tsx | 61 --- .../src/views/stats/MonthlyThroughputCard.tsx | 25 +- frontend-v2/src/views/stats/PulseCard.tsx | 28 - .../src/views/stats/ReciprocityCard.tsx | 56 +- frontend-v2/src/views/stats/RepoLoadCard.tsx | 12 +- .../src/views/stats/ShippingPulseCard.tsx | 106 ++++ .../src/views/stats/ThroughputCard.tsx | 52 -- .../src/views/stats/TimeInReviewCard.tsx | 53 ++ .../src/views/stats/WeeklyDurationCard.tsx | 43 -- frontend-v2/src/views/stats/parts.tsx | 482 +++++++++++++++--- 20 files changed, 966 insertions(+), 427 deletions(-) delete mode 100644 frontend-v2/src/views/stats/FirstCrTrendCard.tsx delete mode 100644 frontend-v2/src/views/stats/MergeAgeByDayCard.tsx delete mode 100644 frontend-v2/src/views/stats/PulseCard.tsx create mode 100644 frontend-v2/src/views/stats/ShippingPulseCard.tsx delete mode 100644 frontend-v2/src/views/stats/ThroughputCard.tsx create mode 100644 frontend-v2/src/views/stats/TimeInReviewCard.tsx delete mode 100644 frontend-v2/src/views/stats/WeeklyDurationCard.tsx diff --git a/frontend-v2/src/model/stats.test.ts b/frontend-v2/src/model/stats.test.ts index 66f3051b..a049813e 100644 --- a/frontend-v2/src/model/stats.test.ts +++ b/frontend-v2/src/model/stats.test.ts @@ -314,14 +314,14 @@ describe('reciprocity', () => { }); describe('authorLoad', () => { - it('counts open PRs per author with their oldest', () => { + it('counts open PRs per author with their oldest and awaiting-CR share', () => { const rows = authorLoad([ - full({ author: 'alice', n: 1, ageDays: 3 }), - full({ author: 'alice', n: 2, ageDays: 9 }), - full({ author: 'bob', n: 3, ageDays: 1 }), + full({ author: 'alice', n: 1, ageDays: 3, status: 'needs_cr' }), + full({ author: 'alice', n: 2, ageDays: 9, status: 'ready' }), + full({ author: 'bob', n: 3, ageDays: 1, status: 'needs_recr' }), ]); - expect(rows[0]).toEqual({ login: 'alice', count: 2, oldestDays: 9 }); - expect(rows[1]).toEqual({ login: 'bob', count: 1, oldestDays: 1 }); + expect(rows[0]).toEqual({ login: 'alice', count: 2, awaitingCr: 1, oldestDays: 9 }); + expect(rows[1]).toEqual({ login: 'bob', count: 1, awaitingCr: 1, oldestDays: 1 }); }); }); diff --git a/frontend-v2/src/model/stats.ts b/frontend-v2/src/model/stats.ts index 25174862..1159a2de 100644 --- a/frontend-v2/src/model/stats.ts +++ b/frontend-v2/src/model/stats.ts @@ -336,15 +336,18 @@ export function reciprocity(pulls: DerivedPull[], closed: PullData[]): Reciproci export interface AuthorLoad { login: string; count: number; + /** how many of those still need CR (the reviewer-facing share) */ + awaitingCr: number; oldestDays: number; } /** who has the most open PRs on the board (work-in-progress load). */ export function authorLoad(pulls: DerivedPull[]): AuthorLoad[] { - const by = new Map(); + const by = new Map(); for (const p of pulls) { - const cur = by.get(p.data.user.login) ?? { count: 0, oldestDays: 0 }; + const cur = by.get(p.data.user.login) ?? { count: 0, awaitingCr: 0, oldestDays: 0 }; cur.count += 1; + if (['needs_cr', 'needs_recr'].includes(p.status)) cur.awaitingCr += 1; cur.oldestDays = Math.max(cur.oldestDays, p.ageDays); by.set(p.data.user.login, cur); } diff --git a/frontend-v2/src/model/statsHistory.test.ts b/frontend-v2/src/model/statsHistory.test.ts index 70bb7ec5..680a0dc4 100644 --- a/frontend-v2/src/model/statsHistory.test.ts +++ b/frontend-v2/src/model/statsHistory.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { fillDayGaps, fillMonthGaps, fillWeekGaps } from './statsHistory'; +import { + explodeMonthToWeeklySamples, + fillDayGaps, + fillMonthGaps, + fillWeekGaps, + resampleWeekly, + weeklyTimeInReview, +} from './statsHistory'; // Fixed anchor so "trailing N periods up to now" is deterministic: 2026-07-18 // (a Saturday, ISO week 29). @@ -56,3 +63,62 @@ describe('fillWeekGaps', () => { ]); }); }); + +describe('resampleWeekly', () => { + it('weight-averages samples landing in the same ISO week', () => { + const samples = [ + { at: new Date(2026, 6, 17).getTime(), value: 10, weight: 2 }, + { at: new Date(2026, 6, 18).getTime(), value: 20, weight: 1 }, + ]; + const [week] = resampleWeekly(samples, ['2026-W29']); + expect(week.isoWeek).toBe('2026-W29'); + expect(week.value).toBeCloseTo((10 * 2 + 20 * 1) / 3, 6); + expect(week.sampled).toBe(3); + }); + + it('zero-fills weeks with no samples, distinct from a real zero', () => { + const [week] = resampleWeekly([], ['2026-W29']); + expect(week).toEqual({ isoWeek: '2026-W29', value: 0, sampled: 0 }); + }); +}); + +describe('explodeMonthToWeeklySamples', () => { + it('spreads a month evenly across its days, splitting the weight', () => { + const samples = explodeMonthToWeeklySamples('2026-07', 4, 31); + expect(samples).toHaveLength(31); + expect(samples.every(s => s.value === 4)).toBe(true); + expect(samples.every(s => s.weight === 1)).toBe(true); + expect(samples[0].at).toBe(new Date(2026, 6, 1).getTime()); + expect(samples.at(-1)!.at).toBe(new Date(2026, 6, 31).getTime()); + }); +}); + +describe('weeklyTimeInReview', () => { + it('resamples month-, week-, and day-native rows onto one weekly grain', () => { + const [week] = weeklyTimeInReview( + [{ month: '2026-07', avgHours: 5, medianHours: 4, sampled: 31 }], + [{ isoWeek: '2026-W29', avgHours: 12, merged: 4 }], + [{ day: '2026-07-18', avgHours: 8, maxHours: 20, merged: 2 }], + 1, + NOW + ); + expect(week.isoWeek).toBe('2026-W29'); + // every July day carries the same monthly value, so the weekly blend + // reproduces it regardless of exactly which July days fall in W29 + expect(week.firstCrMedianHours).toBeCloseTo(4, 6); + expect(week.firstCrAvgHours).toBeCloseTo(5, 6); + // the week- and day-grain merge-time rows both land in W29 and blend + // weighted by their merge counts: (12*4 + 8*2) / (4 + 2) + expect(week.mergeAvgHours).toBeCloseTo((12 * 4 + 8 * 2) / 6, 6); + }); + + it('zero-fills a week with no underlying history', () => { + const [week] = weeklyTimeInReview([], [], [], 1, NOW); + expect(week).toEqual({ + isoWeek: '2026-W29', + firstCrMedianHours: 0, + firstCrAvgHours: 0, + mergeAvgHours: 0, + }); + }); +}); diff --git a/frontend-v2/src/model/statsHistory.ts b/frontend-v2/src/model/statsHistory.ts index 0910aa24..5fac32eb 100644 --- a/frontend-v2/src/model/statsHistory.ts +++ b/frontend-v2/src/model/statsHistory.ts @@ -142,6 +142,147 @@ function round1(n: number): number { return Math.round(n * 10) / 10; } +function parseIsoDate(day: string): number { + const [y, m, d] = day.split('-').map(Number); + return new Date(y, m - 1, d).getTime(); +} + +/** + * The inverse of {@link isoWeekLabel}'s Thursday-of-week rule: the epoch ms + * of the Thursday that owns the given ISO week, so a week-native row (e.g. + * `durationByWeek`) can be dropped into {@link resampleWeekly} alongside + * finer-grained series. + */ +function isoWeekThursday(isoWeek: string): number { + const [yearStr, weekStr] = isoWeek.split('-W'); + const year = Number(yearStr); + const week = Number(weekStr); + const jan4 = new Date(year, 0, 4); + const jan4Thursday = new Date(jan4); + jan4Thursday.setDate(jan4.getDate() + 3 - ((jan4.getDay() + 6) % 7)); + const thursday = new Date(jan4Thursday); + thursday.setDate(jan4Thursday.getDate() + (week - 1) * 7); + return thursday.getTime(); +} + +/** + * A timestamped, weighted sample feeding {@link resampleWeekly}: a value with + * its originating instant and how many real observations back it (a day's + * average of 5 merges outweighs a quiet day of 1 in the weekly blend). + */ +export interface WeightedSample { + at: number; + value: number; + weight: number; +} + +export interface WeeklyResample { + isoWeek: string; + value: number; + /** total sample weight landing in this week — 0 means no signal, not "zero" */ + sampled: number; +} + +/** + * Buckets timestamped, weighted samples into ISO weeks and returns exactly + * one point per week in `weeks` (weight-averaging any samples landing in the + * same week) — so a day-grain series, a week-grain series (one sample per + * week, already at the target grain), and a month-grain series (exploded via + * {@link explodeMonthToWeeklySamples} into one sample per week it spans) can + * all share a single weekly x-axis without each caller hand-rolling its own + * aggregation. + */ +export function resampleWeekly(samples: WeightedSample[], weeks: string[]): WeeklyResample[] { + const by = new Map(); + for (const s of samples) { + const key = isoWeekLabel(s.at); + const cur = by.get(key) ?? { sum: 0, weight: 0 }; + cur.sum += s.value * s.weight; + cur.weight += s.weight; + by.set(key, cur); + } + return weeks.map(isoWeek => { + const cur = by.get(isoWeek); + return { + isoWeek, + value: cur && cur.weight > 0 ? cur.sum / cur.weight : 0, + sampled: cur?.weight ?? 0, + }; + }); +} + +/** + * Spreads a calendar-month row into one weighted sample per day of that + * month, each carrying the month's value and an even share of its weight — + * so {@link resampleWeekly} can fold a monthly series into the weekly grain + * proportional to how many of that month's days fall in each week, instead + * of dumping the whole month onto a single arbitrary week. + */ +export function explodeMonthToWeeklySamples( + month: string, + value: number, + weight: number +): WeightedSample[] { + const [y, m] = month.split('-').map(Number); + const daysInMonth = new Date(y, m, 0).getDate(); + const perDayWeight = weight / daysInMonth; + return Array.from({ length: daysInMonth }, (_, i) => ({ + at: new Date(y, m - 1, i + 1).getTime(), + value, + weight: perDayWeight, + })); +} + +export interface TimeInReviewWeek { + isoWeek: string; + /** median hours to first CR, resampled from the monthly series */ + firstCrMedianHours: number; + /** avg hours to first CR, resampled from the monthly series */ + firstCrAvgHours: number; + /** avg hours open→merge, blended from the week- and day-grain series */ + mergeAvgHours: number; +} + +/** + * The Time in review card's data: the three trend cards it replaced + * (FirstCrTrendCard's monthly median/avg, WeeklyDurationCard's weekly avg, + * MergeAgeByDayCard's daily avg — the latter two both measure open→merge + * duration, so they blend into one "time to merge" series) resampled onto a + * shared weekly x-axis. + */ +export function weeklyTimeInReview( + firstCrByMonth: FirstCrMonthRow[], + durationByWeek: DurationWeekRow[], + mergeAgeByDay: MergeAgeDayRow[], + weeks = 26, + now = Date.now() +): TimeInReviewWeek[] { + const weekKeys = fillWeekGaps([], weeks, now).map(w => w.isoWeek); + const medianSamples = firstCrByMonth.flatMap(m => + explodeMonthToWeeklySamples(m.month, m.medianHours, m.sampled) + ); + const avgCrSamples = firstCrByMonth.flatMap(m => + explodeMonthToWeeklySamples(m.month, m.avgHours, m.sampled) + ); + const mergeSamples: WeightedSample[] = [ + ...durationByWeek.map(w => ({ + at: isoWeekThursday(w.isoWeek), + value: w.avgHours, + weight: w.merged, + })), + ...mergeAgeByDay.map(d => ({ at: parseIsoDate(d.day), value: d.avgHours, weight: d.merged })), + ]; + const median = resampleWeekly(medianSamples, weekKeys); + const avgCr = resampleWeekly(avgCrSamples, weekKeys); + const merge = resampleWeekly(mergeSamples, weekKeys); + return weekKeys.map((isoWeek, i) => ({ + isoWeek, + firstCrMedianHours: median[i].value, + firstCrAvgHours: avgCr[i].value, + mergeAvgHours: merge[i].value, + })); +} + // Dummy mode has no backend to hit: synthesize a plausible year of history so // the Trends cards have something to render in `npm run dev:dummy`. Seeded // with simple modular formulas (no Math.random) so a run is reproducible. diff --git a/frontend-v2/src/views/Stats.tsx b/frontend-v2/src/views/Stats.tsx index c38c9851..12ceee4d 100644 --- a/frontend-v2/src/views/Stats.tsx +++ b/frontend-v2/src/views/Stats.tsx @@ -16,13 +16,7 @@ import { stampsPerDay, statusBreakdown, } from '../model/stats'; -import { - fillDayGaps, - fillFirstCrMonthGaps, - fillMonthGaps, - fillWeekGaps, - useStatsHistory, -} from '../model/statsHistory'; +import { fillMonthGaps, useStatsHistory, weeklyTimeInReview } from '../model/statsHistory'; import { useSettings } from '../settings'; import type { PullData } from '../types'; import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; @@ -30,19 +24,16 @@ import { AgeMixCard } from './stats/AgeMixCard'; import { AuthorLoadCard } from './stats/AuthorLoadCard'; import { DebtCard } from './stats/DebtCard'; import { EffortMixCard } from './stats/EffortMixCard'; -import { FirstCrTrendCard } from './stats/FirstCrTrendCard'; import { FrictionCard } from './stats/FrictionCard'; import { Leaderboard } from './stats/Leaderboard'; -import { MergeAgeByDayCard } from './stats/MergeAgeByDayCard'; import { MergeSizeCard } from './stats/MergeSizeCard'; import { MonthlyThroughputCard } from './stats/MonthlyThroughputCard'; -import { PulseCard } from './stats/PulseCard'; import { ReciprocityCard } from './stats/ReciprocityCard'; import { RepoLoadCard } from './stats/RepoLoadCard'; +import { ShippingPulseCard } from './stats/ShippingPulseCard'; import { StarvationCard } from './stats/StarvationCard'; import { StatusBar } from './stats/StatusBar'; -import { ThroughputCard } from './stats/ThroughputCard'; -import { WeeklyDurationCard } from './stats/WeeklyDurationCard'; +import { TimeInReviewCard } from './stats/TimeInReviewCard'; const WINDOW_DAYS = 14; @@ -62,13 +53,14 @@ function Group({ title, children }: { title: string; children: ReactNode }) { /** * The Stats lens: the board's shape and its review economics at a glance, in - * bands — Flow (what the last two weeks produced), Trends (the same shape - * over months, from a separate history endpoint — hidden if that fetch - * hasn't landed), the board right now (what's open and what it's owed), and - * People (who's carrying it). Everything but Trends is a snapshot of the - * current pool (open PRs plus the 14-day closed window the server keeps), so - * it moves with the scope and filter — scope to a team and these become that - * team's numbers. + * bands ordered most-actionable first — the board right now (what's open and + * what it's owed), Flow (what the last two weeks produced), People (who's + * carrying it), then Trends (the same shape over months, from a separate + * history endpoint — hidden if that fetch hasn't landed, and last because a + * multi-month retrospective is the least time-critical read). Everything but + * Trends is a snapshot of the current pool (open PRs plus the 14-day closed + * window the server keeps), so it moves with the scope and filter — scope to a + * team and these become that team's numbers. */ export function Stats({ pulls, @@ -117,16 +109,15 @@ export function Stats({ // any fetch failure, which hides the whole band below. const history = useStatsHistory(); const monthly = useMemo(() => (history ? fillMonthGaps(history.monthly) : []), [history]); - const mergeAgeDays = useMemo( - () => (history ? fillDayGaps(history.mergeAgeByDay, 60) : []), - [history] - ); - const firstCrMonths = useMemo( - () => (history ? fillFirstCrMonthGaps(history.firstCrByMonth) : []), - [history] - ); - const durationWeeks = useMemo( - () => (history ? fillWeekGaps(history.durationByWeek, 26) : []), + const timeInReview = useMemo( + () => + history + ? weeklyTimeInReview( + history.firstCrByMonth, + history.durationByWeek, + history.mergeAgeByDay + ) + : [], [history] ); @@ -139,32 +130,31 @@ export function Stats({ return (
- - - - - - {history && ( - - - - - - - )} + - + + + + + + - - + {history && ( + + + + + )}
); } diff --git a/frontend-v2/src/views/stats/AuthorLoadCard.tsx b/frontend-v2/src/views/stats/AuthorLoadCard.tsx index 1b8134d6..bbb3713b 100644 --- a/frontend-v2/src/views/stats/AuthorLoadCard.tsx +++ b/frontend-v2/src/views/stats/AuthorLoadCard.tsx @@ -1,7 +1,11 @@ import type { AuthorLoad } from '../../model/stats'; -import { BarRow, PersonCell, StatsCard } from './parts'; +import { PersonCell, SplitBarRow, StatsCard } from './parts'; -/** who's carrying the most open PRs, with each person's oldest one alongside. */ +/** + * Who's carrying the most open PRs. The bar splits into the author's whole + * pile and the amber share of it still awaiting CR, so a big-but-moving + * author reads differently from one whose PRs are all stuck waiting. + */ export function AuthorLoadCard({ rows, me, @@ -17,18 +21,23 @@ export function AuthorLoadCard({ const max = Math.max(...rows.map(r => r.count), 1); const more = rows.length - shown.length; return ( - +
{shown.map(r => ( - 0 ? (r.awaitingCr / r.count) * 100 : 0} color="var(--slate)" + splitColor="var(--warn)" + title={`${r.login}: ${r.count} open, ${r.awaitingCr} awaiting CR, oldest ${r.oldestDays}d`} lead={} trail={ {r.count} - · oldest {r.oldestDays}d + + · {r.awaitingCr} CR · oldest {r.oldestDays}d + } /> diff --git a/frontend-v2/src/views/stats/DebtCard.tsx b/frontend-v2/src/views/stats/DebtCard.tsx index ab7afc2a..0a22ea4d 100644 --- a/frontend-v2/src/views/stats/DebtCard.tsx +++ b/frontend-v2/src/views/stats/DebtCard.tsx @@ -1,12 +1,15 @@ import type { ReviewDebt } from '../../model/stats'; -import { StatsCard } from './parts'; +import { MiniMeter, StatsCard } from './parts'; /** * The board's standing review debt, in stamps. Re-stamps get the amber tint * when nonzero: they're the cheapest debt on the board (the reviewer already - * knows the code) and the most commonly forgotten. + * knows the code) and the most commonly forgotten. Each number carries a thin + * meter scaled to the card's shared max, so relative size is glanceable + * without reading four digits and doing the comparison in your head. */ export function DebtCard({ debt }: { debt: ReviewDebt }) { + const max = Math.max(debt.crSlots, debt.qaSlots, debt.restamps, debt.unclaimedQa, 1); const cell = (value: number, label: string, hint: string, hot?: boolean) => (
{label}
+
); return ( diff --git a/frontend-v2/src/views/stats/EffortMixCard.tsx b/frontend-v2/src/views/stats/EffortMixCard.tsx index 922b9f26..2602daf8 100644 --- a/frontend-v2/src/views/stats/EffortMixCard.tsx +++ b/frontend-v2/src/views/stats/EffortMixCard.tsx @@ -1,28 +1,44 @@ import type { EffortMix } from '../../model/stats'; -import { BarRow, StatsCard, WEIGHT_RAMP } from './parts'; +import { BarRow, Donut, StatsCard, WEIGHT_RAMP } from './parts'; /** * The open board's review effort by weight class — is the pile mostly quick - * reads or heavy lifts? Same XS→XL ramp as the rows' weight meters. + * reads or heavy lifts? Same XS→XL ramp as the rows' weight meters, with a + * compact donut alongside the bars: these five buckets are mutually + * exclusive and sum to the open total, so a part-to-whole glyph is honest + * here in a way it wouldn't be for friction's overlapping categories. */ export function EffortMixCard({ mix }: { mix: EffortMix }) { const max = Math.max(...mix.buckets.map(b => b.count), 1); return ( -
- {mix.buckets.map(b => ( - {b.weight}} - trail={ - - {b.count || ''} - - } - /> - ))} +
+
+ {mix.buckets.map(b => ( + {b.weight}} + trail={ + + {b.count || ''} + + } + /> + ))} +
+ ({ + label: b.weight, + value: b.count, + color: WEIGHT_RAMP[b.weight], + }))} + />
{mix.estimated > 0 && (
diff --git a/frontend-v2/src/views/stats/FirstCrTrendCard.tsx b/frontend-v2/src/views/stats/FirstCrTrendCard.tsx deleted file mode 100644 index 4b1cb98a..00000000 --- a/frontend-v2/src/views/stats/FirstCrTrendCard.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { humanHours } from '../../model/stats'; -import type { FirstCrMonthRow } from '../../model/statsHistory'; -import { type AxisTick, PeriodColumns, StatsCard } from './parts'; - -function monthLabel(month: string): string { - const [y, m] = month.split('-').map(Number); - return new Date(y, m - 1, 1).toLocaleDateString(undefined, { month: 'short' }); -} - -/** - * Monthly first-CR latency, last 12 months. Complements the Shipping card's - * 14-day "first CR in … median" figure: that one is the live window, this is - * whether responsiveness is drifting over the year. - */ -export function FirstCrTrendCard({ months }: { months: FirstCrMonthRow[] }) { - const sampled = months.reduce((a, m) => a + m.sampled, 0); - const periods = months.map(m => ({ - key: m.month, - title: `${monthLabel(m.month)}: median ${humanHours(m.medianHours)} · avg ${humanHours(m.avgHours)} · ${m.sampled} sampled`, - value: m.medianHours, - })); - const axisTicks: AxisTick[] = months - .map((m, i) => (i % 3 === 0 ? { index: i, label: monthLabel(m.month) } : null)) - .filter((t): t is AxisTick => t !== null); - - return ( - - {sampled === 0 ? ( -
No CR'd merges in this window.
- ) : ( -
- -
- )} -
- live window: the Shipping card above · trend: here -
-
- ); -} diff --git a/frontend-v2/src/views/stats/FrictionCard.tsx b/frontend-v2/src/views/stats/FrictionCard.tsx index d0880669..a2bb2212 100644 --- a/frontend-v2/src/views/stats/FrictionCard.tsx +++ b/frontend-v2/src/views/stats/FrictionCard.tsx @@ -1,5 +1,5 @@ import type { Friction } from '../../model/stats'; -import { StatsCard } from './parts'; +import { BarRow, StatsCard } from './parts'; const ROWS: { key: keyof Friction; label: string; dot: string; hint: string }[] = [ { @@ -34,27 +34,38 @@ const ROWS: { key: keyof Friction; label: string; dot: string; hint: string }[] /** * Everything stuck on something other than review attention — the part of the * board no amount of stamping will move. Zero rows stay visible but dimmed: - * "no conflicts" is information. + * "no conflicts" is information. Each row gets a bar scaled to the card's + * shared max, glanceable the same way the rest of Stats reads. */ export function FrictionCard({ friction }: { friction: Friction }) { + const max = Math.max(...ROWS.map(r => friction[r.key]), 1); return ( -
+
{ROWS.map(r => { const n = friction[r.key]; return ( -
- + + + {r.label} + + } + trail={ + + {n} + + } /> - {r.label} - {n}
); })} diff --git a/frontend-v2/src/views/stats/MergeAgeByDayCard.tsx b/frontend-v2/src/views/stats/MergeAgeByDayCard.tsx deleted file mode 100644 index 138f5485..00000000 --- a/frontend-v2/src/views/stats/MergeAgeByDayCard.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { humanHours } from '../../model/stats'; -import type { MergeAgeDayRow } from '../../model/statsHistory'; -import { PeriodColumns, StatsCard } from './parts'; - -/** - * Merge age (open→merge) per day, last 60 days. Bars are the daily average, - * scaled to the average series — NOT to the outliers, which used to compress - * every bar into an identical stub. The outlier story gets exactly one mark: - * the window's slowest merge is flagged on its day and named in the summary - * line. (A per-day max tick at 60-column density rendered as sixty floating - * specks — noise, not information; each day's slowest still lives in its - * tooltip.) - */ -export function MergeAgeByDayCard({ days }: { days: MergeAgeDayRow[] }) { - const merged = days.reduce((a, d) => a + d.merged, 0); - const withData = days.filter(d => d.merged > 0); - const overallAvg = withData.length - ? withData.reduce((a, d) => a + d.avgHours, 0) / withData.length - : 0; - const slowest = withData.reduce( - (worst: MergeAgeDayRow | null, d) => (d.maxHours > (worst?.maxHours ?? 0) ? d : worst), - null - ); - const periods = days.map(d => ({ - key: d.day, - title: `${d.day}: avg ${humanHours(d.avgHours)} · slowest ${humanHours(d.maxHours)} · ${d.merged} merged`, - value: d.avgHours, - // one mark in the whole chart: the window's slowest merge — its real - // value, which the marker clamp pins to the top edge of its column - marker: slowest && d.day === slowest.day ? d.maxHours : undefined, - })); - - return ( - -
- -
-
- {merged > 0 ? ( - <> - {merged} merged - · - avg{' '} - {humanHours(overallAvg)} - {slowest && ( - <> - · - slowest{' '} - - {humanHours(slowest.maxHours)} - - - )} - - ) : ( - No merges in this window. - )} -
-
- ); -} diff --git a/frontend-v2/src/views/stats/MonthlyThroughputCard.tsx b/frontend-v2/src/views/stats/MonthlyThroughputCard.tsx index 20f1af84..86819fdc 100644 --- a/frontend-v2/src/views/stats/MonthlyThroughputCard.tsx +++ b/frontend-v2/src/views/stats/MonthlyThroughputCard.tsx @@ -1,5 +1,5 @@ import type { MonthlyRow } from '../../model/statsHistory'; -import { type AxisTick, type PairedPeriod, PairedPeriodColumns, StatsCard } from './parts'; +import { type AxisTick, LineChart, StatsCard } from './parts'; function monthLabel(month: string): string { const [y, m] = month.split('-').map(Number); @@ -9,28 +9,27 @@ function monthLabel(month: string): string { /** * Opened vs merged per month, last 12 months — the Shipping card's window * stretched from a fortnight to a year, so a seasonal slowdown or a sustained - * ramp shows up instead of getting lost in the 14-day frame. + * ramp shows up instead of getting lost in the 14-day frame. The shaded area + * between the two lines is the delta itself: opened above merged means the + * backlog grew that stretch, merged above opened means it shrank. */ export function MonthlyThroughputCard({ monthly }: { monthly: MonthlyRow[] }) { const totalOpened = monthly.reduce((a, m) => a + m.opened, 0); const totalMerged = monthly.reduce((a, m) => a + m.merged, 0); - const periods: PairedPeriod[] = monthly.map(m => ({ - key: m.month, - title: `${monthLabel(m.month)}: ${m.opened} opened · ${m.merged} merged`, - a: m.opened, - b: m.merged, - })); const axisTicks: AxisTick[] = monthly .map((m, i) => (i % 3 === 0 ? { index: i, label: monthLabel(m.month) } : null)) .filter((t): t is AxisTick => t !== null); return ( - +
- m.opened) }, + { label: 'merged', color: 'var(--ok)', values: monthly.map(m => m.merged) }, + ]} + band={{ a: 0, b: 1, color: 'var(--brand)' }} axisTicks={axisTicks} />
diff --git a/frontend-v2/src/views/stats/PulseCard.tsx b/frontend-v2/src/views/stats/PulseCard.tsx deleted file mode 100644 index 49ea68ba..00000000 --- a/frontend-v2/src/views/stats/PulseCard.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { DayCount } from '../../model/stats'; -import { MiniColumns, StatsCard } from './parts'; - -/** - * Review pulse: CR+QA stamps landed per day. Re-stamps count — they're real - * review work — so this reads "how much reviewing happened", the input-side - * twin of the Shipping card's output. - */ -export function PulseCard({ perDay }: { perDay: DayCount[] }) { - const total = perDay.reduce((a, d) => a + d.count, 0); - const quietDays = perDay.filter(d => d.count === 0).length; - return ( - -
- -
-
- {total} stamps - {quietDays > 0 && ( - - {' '} - · {quietDays} quiet day{quietDays > 1 ? 's' : ''} - - )} -
-
- ); -} diff --git a/frontend-v2/src/views/stats/ReciprocityCard.tsx b/frontend-v2/src/views/stats/ReciprocityCard.tsx index 871ed46d..21eee934 100644 --- a/frontend-v2/src/views/stats/ReciprocityCard.tsx +++ b/frontend-v2/src/views/stats/ReciprocityCard.tsx @@ -1,10 +1,12 @@ import type { Reciprocity } from '../../model/stats'; -import { BarRow, PersonCell, StatsCard } from './parts'; +import { PairedBarRow, PersonCell, StatsCard } from './parts'; /** - * Review give-and-take: PRs you stamped for others (the bar) against stamps - * others put on yours (the trailing number). A healthy board reads roughly - * balanced; all-bar people are carrying it, no-bar people are coasting. + * Review give-and-take: PRs you stamped for others against stamps others put + * on yours, as two bars sharing one scale instead of a bar plus a trailing + * number the reader had to hold in their head. A healthy board reads roughly + * balanced; all-given people are carrying it, all-received people are + * coasting. */ export function ReciprocityCard({ rows, @@ -18,24 +20,52 @@ export function ReciprocityCard({ cap?: number; }) { const shown = rows.slice(0, cap); - const max = Math.max(...rows.map(r => r.given), 1); + const max = Math.max(...rows.flatMap(r => [r.given, r.received]), 1); const more = rows.length - shown.length; return ( - + {shown.length === 0 ? (
No review activity in this window.
) : ( -
+
+
+ + + given + + + + received + +
{shown.map(r => ( - } - trail={ - - {r.given} / {r.received} + aTrail={ + + {r.given} + + } + bTrail={ + + {r.received} } /> diff --git a/frontend-v2/src/views/stats/RepoLoadCard.tsx b/frontend-v2/src/views/stats/RepoLoadCard.tsx index 63567406..88372d64 100644 --- a/frontend-v2/src/views/stats/RepoLoadCard.tsx +++ b/frontend-v2/src/views/stats/RepoLoadCard.tsx @@ -1,11 +1,11 @@ import { shortRepo } from '../../format'; import type { RepoLoad } from '../../model/stats'; -import { BarRow, StatsCard } from './parts'; +import { SplitBarRow, StatsCard } from './parts'; /** - * Where the open PRs live. The bar is the repo's whole pile; the trailing - * numbers split out how much of it is still review work and how old the - * oldest is, so a big-but-moving repo reads differently from a stuck one. + * Where the open PRs live. The bar splits into the repo's whole pile and the + * amber share of it still awaiting CR — the reviewer-facing debt, not just + * the total count — plus the oldest age trailing. */ export function RepoLoadCard({ rows, cap = 10 }: { rows: RepoLoad[]; cap?: number }) { const shown = rows.slice(0, cap); @@ -15,10 +15,12 @@ export function RepoLoadCard({ rows, cap = 10 }: { rows: RepoLoad[]; cap?: numbe
{shown.map(r => ( - 0 ? (r.awaitingCr / r.count) * 100 : 0} color="var(--ink-3)" + splitColor="var(--warn)" title={r.repo} lead={ diff --git a/frontend-v2/src/views/stats/ShippingPulseCard.tsx b/frontend-v2/src/views/stats/ShippingPulseCard.tsx new file mode 100644 index 00000000..009b7843 --- /dev/null +++ b/frontend-v2/src/views/stats/ShippingPulseCard.tsx @@ -0,0 +1,106 @@ +import { humanHours, type DayCount, type Latency } from '../../model/stats'; +import { type AxisTick, LineChart, StatsCard } from './parts'; + +/** + * Volume vs. speed, one panel: merges per day (bars, left axis) against + * CR+QA stamps per day (line, right axis) — the Shipping and Review-pulse + * cards fused, since "how much shipped" only means something next to "how + * much reviewing happened" the same days. First-CR median rides along as the + * responsiveness number time-to-merge (which includes the author's own + * iteration) hides. + */ +export function ShippingPulseCard({ + perDay, + pulse, + firstCr, +}: { + perDay: DayCount[]; + pulse: DayCount[]; + firstCr: Latency; +}) { + const total = perDay.reduce((a, d) => a + d.count, 0); + const half = Math.floor(perDay.length / 2); + const lastWeek = perDay.slice(0, half).reduce((a, d) => a + d.count, 0); + const thisWeek = perDay.slice(half).reduce((a, d) => a + d.count, 0); + const delta = thisWeek - lastWeek; + const pulseTotal = pulse.reduce((a, d) => a + d.count, 0); + const quietDays = pulse.filter(d => d.count === 0).length; + + const axisTicks: AxisTick[] = [0, Math.floor((perDay.length - 1) / 2), perDay.length - 1] + .filter((idx, i, arr) => idx >= 0 && arr.indexOf(idx) === i) + .map(index => ({ + index, + label: new Date(perDay[index].day).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }), + })); + + return ( + +
+ d.count), + axis: 'left', + }, + ]} + series={[ + { + label: 'stamps/day', + color: 'var(--brand)', + values: pulse.map(d => d.count), + axis: 'right', + }, + ]} + axisTicks={axisTicks} + /> +
+
+ + {total} merged + + + this week {thisWeek} vs{' '} + {lastWeek} + {delta !== 0 && ( + 0 ? 'var(--ok)' : 'var(--warn)' }} + > + {delta > 0 ? '+' : ''} + {delta} + + )} + + + {pulseTotal} stamps + {quietDays > 0 && ( + + {' '} + · {quietDays} quiet day{quietDays > 1 ? 's' : ''} + + )} + + {firstCr.sampled > 0 && ( + + first CR in{' '} + + {humanHours(firstCr.medianHours)} + {' '} + median + + )} +
+
+ ); +} diff --git a/frontend-v2/src/views/stats/ThroughputCard.tsx b/frontend-v2/src/views/stats/ThroughputCard.tsx deleted file mode 100644 index ce6eee56..00000000 --- a/frontend-v2/src/views/stats/ThroughputCard.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { humanHours, type DayCount, type Latency } from '../../model/stats'; -import { MiniColumns, StatsCard } from './parts'; - -/** - * Shipping cadence over the closed window: merges per day, this week against - * last, and how fast a PR gets its first CR — the responsiveness number that - * time-to-merge (which includes the author's own iteration) hides. - */ -export function ThroughputCard({ perDay, firstCr }: { perDay: DayCount[]; firstCr: Latency }) { - const total = perDay.reduce((a, d) => a + d.count, 0); - const half = Math.floor(perDay.length / 2); - const lastWeek = perDay.slice(0, half).reduce((a, d) => a + d.count, 0); - const thisWeek = perDay.slice(half).reduce((a, d) => a + d.count, 0); - const delta = thisWeek - lastWeek; - - return ( - -
- -
-
- - {total} merged - - - this week {thisWeek} vs{' '} - {lastWeek} - {delta !== 0 && ( - 0 ? 'var(--ok)' : 'var(--warn)' }} - > - {delta > 0 ? '+' : ''} - {delta} - - )} - - {firstCr.sampled > 0 && ( - - first CR in{' '} - - {humanHours(firstCr.medianHours)} - {' '} - median - - )} -
-
- ); -} diff --git a/frontend-v2/src/views/stats/TimeInReviewCard.tsx b/frontend-v2/src/views/stats/TimeInReviewCard.tsx new file mode 100644 index 00000000..a5b18051 --- /dev/null +++ b/frontend-v2/src/views/stats/TimeInReviewCard.tsx @@ -0,0 +1,53 @@ +import type { TimeInReviewWeek } from '../../model/statsHistory'; +import { type AxisTick, LineChart, StatsCard } from './parts'; + +/** + * Replaces the three separate trend cards (monthly first-CR latency, weekly + * PR duration, daily merge age) with one weekly chart: how long a PR waits + * for its first review, and how long it takes to merge, over the last + * half-year. All three source series ran on different cadences (month/week/ + * day) — `weeklyTimeInReview` in statsHistory.ts resamples them onto a + * shared weekly x-axis so responsiveness and throughput drift show up on the + * same chart instead of three disconnected ones. + */ +export function TimeInReviewCard({ weeks }: { weeks: TimeInReviewWeek[] }) { + const sampled = weeks.some(w => w.firstCrMedianHours > 0 || w.mergeAvgHours > 0); + const axisTicks: AxisTick[] = weeks + .map((w, i) => (i % 4 === 0 ? { index: i, label: w.isoWeek.replace(/^\d+-/, '') } : null)) + .filter((t): t is AxisTick => t !== null); + + return ( + + {!sampled ? ( +
Not enough history yet.
+ ) : ( +
+ w.firstCrMedianHours), + area: true, + }, + { + label: 'time to first CR (avg)', + color: 'var(--ink-3)', + values: weeks.map(w => w.firstCrAvgHours), + dashed: true, + }, + { + label: 'time to merge (avg)', + color: 'var(--violet)', + values: weeks.map(w => w.mergeAvgHours), + }, + ]} + axisTicks={axisTicks} + /> +
+ )} +
hours · lower is faster
+
+ ); +} diff --git a/frontend-v2/src/views/stats/WeeklyDurationCard.tsx b/frontend-v2/src/views/stats/WeeklyDurationCard.tsx deleted file mode 100644 index 1886b868..00000000 --- a/frontend-v2/src/views/stats/WeeklyDurationCard.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { humanHours } from '../../model/stats'; -import type { DurationWeekRow } from '../../model/statsHistory'; -import { type AxisTick, PeriodColumns, StatsCard } from './parts'; - -/** - * Average PR duration (open→merge) per ISO week, last 26 weeks — the - * merge-time-by-size card's twin over time instead of over diff size. - */ -export function WeeklyDurationCard({ weeks }: { weeks: DurationWeekRow[] }) { - const merged = weeks.reduce((a, w) => a + w.merged, 0); - const withData = weeks.filter(w => w.merged > 0); - const overallAvg = withData.length - ? withData.reduce((a, w) => a + w.avgHours, 0) / withData.length - : 0; - const periods = weeks.map(w => ({ - key: w.isoWeek, - title: `${w.isoWeek}: avg ${humanHours(w.avgHours)} · ${w.merged} merged`, - value: w.avgHours, - })); - const axisTicks: AxisTick[] = weeks - .map((w, i) => (i % 4 === 0 ? { index: i, label: w.isoWeek.replace(/^\d+-/, '') } : null)) - .filter((t): t is AxisTick => t !== null); - - return ( - -
- -
-
- {merged > 0 ? ( - <> - {merged} merged - · - avg{' '} - {humanHours(overallAvg)} - - ) : ( - No merges in this window. - )} -
-
- ); -} diff --git a/frontend-v2/src/views/stats/parts.tsx b/frontend-v2/src/views/stats/parts.tsx index 41ef6006..56ed268d 100644 --- a/frontend-v2/src/views/stats/parts.tsx +++ b/frontend-v2/src/views/stats/parts.tsx @@ -101,16 +101,6 @@ export function MiniColumns({ ); } -export interface Period { - /** stable React key, e.g. "2026-07" or "2026-W29" */ - key: string; - /** tooltip text */ - title: string; - value: number; - /** an optional secondary value (e.g. that period's max) drawn as a thin mark */ - marker?: number; -} - /** where to print an axis label, e.g. one per quarter on a 12-month chart */ export interface AxisTick { index: number; @@ -135,99 +125,441 @@ function AxisLabels({ ticks, count }: { ticks?: AxisTick[]; count: number }) { } /** - * A month/week/day-per-column chart, like MiniColumns but for longer history - * windows: each column carries a title tooltip and an optional thin marker - * line (e.g. that period's max, above an avg bar) so an outlier doesn't - * vanish inside a tame-looking average. Optional axis ticks label a subset of - * columns (e.g. every quarter) since 12+ columns is too dense to label all of. + * A {@link BarRow} whose fill carries a highlighted inner segment — a + * subset's share of the row's OWN total (e.g. how much of a repo's pile is + * still awaiting CR) — instead of trailing it as text the reader has to + * cross-reference against the bar. */ -export function PeriodColumns({ - periods, +export function SplitBarRow({ + lead, + pct, + splitPct, color, - axisTicks, + splitColor, + trail, + title, }: { - periods: Period[]; + lead: ReactNode; + /** 0–100, the row's total as a share of the column max */ + pct: number; + /** 0–100, the highlighted subset as a share of THIS row's own total */ + splitPct: number; color: string; - axisTicks?: AxisTick[]; + splitColor: string; + trail?: ReactNode; + title?: string; }) { - // scale to the value series only — a marker (outlier callout) clamps to the - // top edge instead of dragging every bar down into an unreadable stub - const max = Math.max(...periods.map(p => p.value), 1); return ( -
-
- {periods.map(p => ( -
+
+ {lead} +
+
+ {splitPct > 0 && (
- {p.marker != null && p.marker > 0 && ( -
- )} -
- ))} + )} +
- + {trail}
); } -export interface PairedPeriod { - key: string; - title: string; - a: number; - b: number; -} - /** - * Two-series twin of {@link PeriodColumns} — a pair of adjacent bars per - * column (e.g. opened vs merged per month) sharing one scale. + * Two thin bars stacked in one row, sharing one scale — a person's "given" + * over their "received" (or any other two-series-per-entity comparison) + * where both numbers need to read as comparably sized, not one bar plus + * trailing text. */ -export function PairedPeriodColumns({ - periods, +export function PairedBarRow({ + lead, + aPct, + bPct, colorA, colorB, - axisTicks, + aTrail, + bTrail, + title, }: { - periods: PairedPeriod[]; + lead: ReactNode; + aPct: number; + bPct: number; colorA: string; colorB: string; - axisTicks?: AxisTick[]; + aTrail?: ReactNode; + bTrail?: ReactNode; + title?: string; }) { - const max = Math.max(...periods.map(p => Math.max(p.a, p.b)), 1); return ( -
-
- {periods.map(p => ( -
+
+ {lead} +
+
+
+
+ {aTrail} +
+
+
- ))} + {bTrail} +
- +
+ ); +} + +/** + * A single thin fill bar with no label — the shared-max glyph under a + * standalone number tile (Debt's 2×2 grid) where a full {@link BarRow}'s + * lead/trail row layout doesn't fit. + */ +export function MiniMeter({ pct, color }: { pct: number; color: string }) { + return ( +
+
+
+ ); +} + +export interface LineSeries { + label: string; + color: string; + values: number[]; + /** which y-axis this series scales against; default 'left' */ + axis?: 'left' | 'right'; + dashed?: boolean; + /** low-opacity fill from the line down to the zero baseline — reserve for one primary series */ + area?: boolean; +} + +export interface BarSeries { + label: string; + color: string; + values: number[]; + axis?: 'left' | 'right'; +} + +export interface LineChartBand { + /** index into `series` for the two lines to shade the area between */ + a: number; + b: number; + color?: string; +} + +const CHART_W = 600; + +function axisExtent(values: number[]): { min: number; max: number } { + const min = Math.min(0, ...values, 0); + const max = Math.max(...values, 1); + return { min, max: max <= min ? min + 1 : max }; +} + +function scaleY(v: number, min: number, max: number, top: number, bottom: number): number { + if (max === min) return bottom; + return bottom - ((v - min) / (max - min)) * (bottom - top); +} + +/** + * An inline multi-series SVG line chart — hand-rolled to the same "component + * with hard-coded scale math" style as {@link PeriodColumns}, since no + * charting library is a dependency here. Supports an optional bar series + * underlay (for a bars-vs-line "volume vs speed" panel), a dual left/right + * axis, a low-opacity area fill under one primary line, and a shaded band + * between two lines (e.g. opened vs merged, so backlog growth/shrink reads + * at a glance). Handles zero, one, or short series without throwing — an + * empty chart just renders its gridlines. + */ +export function LineChart({ + series, + bars, + axisTicks, + band, + height = 120, + ariaLabel, +}: { + series: LineSeries[]; + bars?: BarSeries[]; + axisTicks?: AxisTick[]; + band?: LineChartBand; + /** chart height in px; width always fills the container */ + height?: number; + ariaLabel: string; +}) { + const n = Math.max( + 0, + ...series.map(s => s.values.length), + ...(bars ?? []).map(b => b.values.length) + ); + const pad = 6; + const top = 8; + const bottom = height - 8; + const leftValues = [ + ...series.filter(s => s.axis !== 'right').flatMap(s => s.values), + ...(bars ?? []).filter(b => b.axis !== 'right').flatMap(b => b.values), + ]; + const rightValues = [ + ...series.filter(s => s.axis === 'right').flatMap(s => s.values), + ...(bars ?? []).filter(b => b.axis === 'right').flatMap(b => b.values), + ]; + const left = axisExtent(leftValues); + const right = axisExtent(rightValues); + const x = (i: number) => (n <= 1 ? CHART_W / 2 : pad + (i * (CHART_W - 2 * pad)) / (n - 1)); + const yFor = (v: number, axis?: 'left' | 'right') => + axis === 'right' + ? scaleY(v, right.min, right.max, top, bottom) + : scaleY(v, left.min, left.max, top, bottom); + const gridLines = [0, 0.25, 0.5, 0.75, 1].map(f => top + f * (bottom - top)); + const slot = n > 0 ? (CHART_W - 2 * pad) / n : 0; + const hasLegend = series.length > 0 || (bars?.length ?? 0) > 0; + + return ( +
+ + {ariaLabel} + {gridLines.map(y => ( + + ))} + {bars?.map(b => { + const barW = Math.max(slot * 0.55, 1.5); + return ( + + {b.values.map((v, i) => { + const y0 = yFor(0, b.axis); + const y1 = yFor(v, b.axis); + return ( + + ); + })} + + ); + })} + {band && series[band.a] && series[band.b] && n > 1 && ( + `${x(i)},${yFor(v, series[band.a].axis)}` + ), + ...[...series[band.b].values] + .map((v, i) => `${x(i)},${yFor(v, series[band.b].axis)}`) + .reverse(), + ].join(' ')} + fill={band.color ?? series[band.a].color} + opacity={0.12} + /> + )} + {series.map(s => { + if (s.values.length === 0) return null; + const pts = s.values.map((v, i) => [x(i), yFor(v, s.axis)] as const); + const path = pts.map(([px, py], i) => `${i === 0 ? 'M' : 'L'}${px},${py}`).join(' '); + const [lastX, lastY] = pts[pts.length - 1]; + const baseline = yFor(0, s.axis); + return ( + + {s.area && pts.length > 1 && ( + `${px},${py}`).join(' ')} ${lastX},${baseline} ${pts[0][0]},${baseline}`} + fill={s.color} + opacity={0.12} + /> + )} + {pts.length > 1 && ( + + )} + + + ); + })} + + + {hasLegend && ( +
+ {series.map(s => ( + + + {s.label} + + ))} + {bars?.map(b => ( + + + {b.label} + + ))} +
+ )} +
+ ); +} + +export interface DonutSlice { + label: string; + value: number; + color: string; +} + +/** + * A compact part-to-whole donut for a mutually-exclusive set (e.g. the five + * weight buckets summing to the open total) — a center total plus a compact + * legend. Not for categories that can overlap (friction, debt): those stay + * bars, since a donut implies the slices sum to the whole. + */ +export function Donut({ + slices, + size = 84, + thickness = 12, + centerSub, + ariaLabel, +}: { + slices: DonutSlice[]; + size?: number; + thickness?: number; + /** small caption under the center total, e.g. "open" */ + centerSub?: string; + ariaLabel: string; +}) { + const total = slices.reduce((a, s) => a + s.value, 0); + const r = (size - thickness) / 2; + const c = 2 * Math.PI * r; + const cx = size / 2; + const cy = size / 2; + let offset = 0; + + return ( +
+ + {ariaLabel} + + {total > 0 && + slices + .filter(s => s.value > 0) + .map(s => { + const frac = s.value / total; + const dash = frac * c; + const el = ( + + ); + offset += dash; + return el; + })} + + {total} + + {centerSub && ( + + {centerSub} + + )} + +
    + {slices.map(s => ( +
  • + + {s.label} + {s.value} +
  • + ))} +
); } From 1317ef12ad2f4deec13073d73187e0d5b7215ea8 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 15:55:41 -0700 Subject: [PATCH 181/381] Show Deal-me-one's pick as a card in a popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old result was a bare "Dealt repo#42" label with no title, no link, and Claim/Pass buttons — you couldn't tell what you were about to take on without hunting for the row it scrolled to. Render the dealt pull as a real card anchored to the button instead: avatar and author, the title as a GitHub link, the same weight / size / age / state metrics a row carries, the CR-QA pips, and one line on why it came up. Claiming deals the next straight away so a run of triage doesn't mean reopening; Pass skips without claiming; clicking away ends the session. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/views/Review.tsx | 186 +++++++++++++++++++++++-------- 1 file changed, 139 insertions(+), 47 deletions(-) diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 78507df3..bbbb9431 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -1,68 +1,143 @@ import { useState } from 'react'; import { type DerivedPull, qaDone, type Status, weightRank } from '../model/status'; -import { pullKey, rowDomId, shortRepo } from '../format'; +import { epoch, pullKey } from '../format'; import { crSort, starFirst } from '../model/sort'; import { matchesRegion } from '../model/regions'; import { groupIntoTree } from '../model/stack'; import { authorMove, reviewerMove } from '../model/actions'; import { reviewRequestedFrom } from '../model/reviewers'; +import { startHereReason } from '../model/cheers'; import { dealOne } from '../model/deal'; import { useSettings } from '../settings'; import { claimReview, isFresh, usePulldasher } from '../store'; import type { PullData } from '../types'; -import { EmptyState, QuietButton, STATUS_DOT, STATUS_LABEL } from '../components/bits'; +import { + AgeStamp, + Avatar, + DiffSize, + EmptyState, + PullTitleLink, + QuietButton, + RepoRef, + SigPips, + STATUS_DOT, + STATUS_LABEL, + WeightMeter, +} from '../components/bits'; import { Fold, FoldRows, Lane, laneShown, RestGroup, Truncated } from '../components/Lane'; +import { Popover } from '../components/Popover'; import { markDealtFlash, Row, type RowOptions } from '../components/Row'; import { ClosedRow } from '../components/ClosedRow'; /** - * "Deal me one": for a reviewer who'd rather click than browse, pick the single - * best pull from the review queue and scroll to it — but stop short of - * claiming. Claiming now also puts you on the PR as a GitHub reviewer, so it's - * a real commitment: the dealt pull waits behind a "Claim it" confirmation. - * "Pass" skips it (nothing to release, since a deal no longer claims) and deals - * the next, so you can flip through the queue a card at a time without opening - * the lane. + * The dealt pull, shown as a real card inside the Deal-me-one popover: avatar + * and author, the title as a GitHub link, the same weight / size / age / state + * metrics a row carries, the CR-QA pips, and one line on why this one came up. + * Claiming it is a commitment (it also adds you as a GitHub reviewer), so it's + * an explicit button, not a side effect of dealing. + */ +function DealtCard({ + pull, + me, + pulls, + onClaim, + onPass, +}: { + pull: DerivedPull; + me: string; + pulls: DerivedPull[]; + onClaim: () => void; + onPass: () => void; +}) { + const d = pull.data; + return ( +
+
+ + {d.user.login} + +
+
+ +
+
+ + {pull.sizeKnown && ( + + )} + {STATUS_LABEL[pull.status]} + +
+
+ + +
+
+ 🎯 + {startHereReason(pull, pulls, me)} +
+
+ + Claim it + + Pass +
+
+ ); +} + +/** + * "Deal me one": for a reviewer who'd rather be handed the next pull than + * browse, pick the single best one out of the review queue and show it as a + * card in a popover anchored to the button. "Claim it" takes it (and adds you + * as a GitHub reviewer) then immediately deals the next, so a run of triage + * doesn't mean reopening; "Pass" skips to the next without claiming. Clicking + * away or Escape ends the session. Opening always deals a fresh pull. */ function DealButton({ queue, opts }: { queue: DerivedPull[]; opts: RowOptions }) { const { pulls } = usePulldasher(); + const me = opts.me; const [dealtKey, setDealtKey] = useState(null); const [passed, setPassed] = useState>(new Set()); - // self-clearing, same pattern as Settings' refreshNote — a transient - // "nothing to deal" note, not a standing empty state - const [note, setNote] = useState(''); const deal = (passedNow: ReadonlySet) => { - const picked = dealOne(queue, { - me: opts.me, - pulls, - claims: opts.claims ?? {}, - passed: passedNow, - }); - if (!picked) { - setDealtKey(null); - setNote('nothing to deal'); - setTimeout(() => setNote(''), 2500); - return; - } - setDealtKey(pullKey(picked.data)); - const id = rowDomId(picked.data); - requestAnimationFrame(() => { - document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); + const picked = dealOne(queue, { me, pulls, claims: opts.claims ?? {}, passed: passedNow }); + setDealtKey(picked ? pullKey(picked.data) : null); }; const dealt = dealtKey ? queue.find(p => pullKey(p.data) === dealtKey) : null; const claim = () => { - if (!dealt) return; - // flash first, then claim: the claim's store publish re-renders the row a - // beat later (the only thing that makes markDealtFlash's flash paint), so - // the card lights up exactly as its claim badge appears — the confirmation - markDealtFlash(dealtKey!); + if (!dealtKey || !dealt) return; + // flash the row as its claim badge appears (the store publish re-renders + // it a beat later, which is what paints markDealtFlash's highlight), then + // deal the next straight away for an uninterrupted run of triage + markDealtFlash(dealtKey); claimReview(dealt.data); - setDealtKey(null); - setPassed(new Set()); + const next = new Set(passed); + next.add(dealtKey); + setPassed(next); + deal(next); }; const pass = () => { @@ -74,20 +149,37 @@ function DealButton({ queue, opts }: { queue: DerivedPull[]; opts: RowOptions }) }; return ( - - {note && {note}} + ( + + )} + > {dealt ? ( - <> - - Dealt {shortRepo(dealt.data.repo)}#{dealt.data.number} - - Claim it - Pass - + ) : ( - deal(passed)}>Deal me one +
Nothing left to deal in this queue.
)} -
+ ); } From eabada388a95a1bcc464883148ad028396f51a3b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 15:58:59 -0700 Subject: [PATCH 182/381] Keep dismissed nudges in a notification panel Toasts are deliberately non-sticky, but that meant a nudge you missed or that timed out was gone for good. Keep the last 40 in session memory and surface them behind a bell in the header: the same list the stack fired, newest first, each still linking its PR. A brand count on the bell shows what's landed since you last opened it. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 15 ++- .../src/components/NotificationPanel.tsx | 108 ++++++++++++++++++ frontend-v2/src/toasts.tsx | 20 +++- 3 files changed, 134 insertions(+), 9 deletions(-) create mode 100644 frontend-v2/src/components/NotificationPanel.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 5917a200..2aac4a9c 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -27,6 +27,7 @@ import { matchesQuery } from './model/query'; import { CRYO_KEY, isBotLogin, personHidden, repoHidden } from './model/visibility'; import { foldDomId, openFold } from './components/Lane'; import { Legend } from './components/Legend'; +import { NotificationPanel } from './components/NotificationPanel'; import { RepoFilter } from './components/filters/RepoFilter'; import { PeopleFilter } from './components/filters/PeopleFilter'; import { WeightFilter } from './components/filters/WeightFilter'; @@ -764,14 +765,11 @@ export function App() { setLens('review'); setWeightSel(['XS', 'S']); }, []); - const { toasts, dismiss: dismissToast } = useToasts( - pulls, - me, - claims, - shippedExtras, - closed, - onQuickWins - ); + const { + toasts, + dismiss: dismissToast, + history: toastHistory, + } = useToasts(pulls, me, claims, shippedExtras, closed, onQuickWins); return ( <> @@ -819,6 +817,7 @@ export function App() { > v1 board + = { + reward: 'bg-brand-50 text-brand', + info: 'bg-brand-50 text-brand', + nag: 'bg-secondary text-ink-2', +}; + +/** + * The recent-nudges panel: toasts are deliberately non-sticky, so this is + * where a nudge you missed (or dismissed) stays recoverable — the same list + * the toast stack fired, newest first, each still linking its PR. A brand dot + * on the bell counts what's landed since you last opened it. + */ +export function NotificationPanel({ records }: { records: ToastRecord[] }) { + // session-only "last opened" — toasts are session-only too, so there's + // nothing to persist. Everything fired after this counts as unseen. + const [lastSeen, setLastSeen] = useState(0); + const unseen = records.filter(r => r.at > lastSeen).length; + + return ( + ( + + )} + > + + Recent nudges + + {records.length === 0 ? ( +
No nudges yet.
+ ) : ( + + )} +
+ ); +} diff --git a/frontend-v2/src/toasts.tsx b/frontend-v2/src/toasts.tsx index 8a709c31..8a791803 100644 --- a/frontend-v2/src/toasts.tsx +++ b/frontend-v2/src/toasts.tsx @@ -19,6 +19,17 @@ const TTL_MS: Record = { reward: 5000, nag: 7000, info: 9000 const LEAVE_MS = 200; /** Most toasts on screen at once; a fourth pushes the oldest out early. */ const MAX_VISIBLE = 3; +/** How many past nudges the notification panel keeps — toasts are non-sticky + * and session-only, but they shouldn't vanish for good the moment they fade. */ +const HISTORY_CAP = 40; + +/** A fired toast kept for the notification panel: the toast itself plus when it + * fired (ms epoch), so a dismissed nudge stays recoverable. */ +export interface ToastRecord { + id: number; + toast: Toast; + at: number; +} /** Dev-only samples for previewing the toast visuals (see the __pdCheers hook * below) — one of every tone/kind, so the whole catalog is QA-able against the @@ -121,8 +132,10 @@ export function useToasts( onQuickWins?: () => void ) { const [toasts, setToasts] = useState([]); + const [history, setHistory] = useState([]); const baseline = useRef(EMPTY_BASELINE); const nextId = useRef(0); + const histId = useRef(0); const timers = useRef>>(new Map()); const toastsRef = useRef([]); const firedKeys = useRef>(new Set()); @@ -159,6 +172,11 @@ export function useToasts( const push = useCallback( (fresh: Toast[]) => { if (fresh.length === 0) return; + // keep every fired nudge for the panel, newest first — separate id + // space from the live stack, since the panel outlives the toast + const firedAt = Date.now(); + const records = fresh.map(t => ({ id: histId.current++, toast: t, at: firedAt })); + setHistory(h => [...records, ...h].slice(0, HISTORY_CAP)); setToasts(list => { const added = fresh.map(t => ({ ...t, id: nextId.current++ })); let merged = [...list, ...added]; @@ -238,7 +256,7 @@ export function useToasts( }; }, []); - return { toasts, dismiss }; + return { toasts, dismiss, history }; } function Sparks() { From 3b7b5857ee76ab8e300eee28fb2c09308b44aea0 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 16:00:54 -0700 Subject: [PATCH 183/381] Remember every rest-group fold's open state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds already persist their open/closed choice per id (pd2.folds, a native
accordion), falling back to defaultOpen only for folds nobody has touched — remembered state, not a setting, which is what a board you return to all day wants. But eight of Review's rest-group folds (ready, stamped, QA'd, the block states, CI pending, drafts) had no id, so opening one never stuck. Give each a stable id so every section remembers, the same as Team, People, and MyWork already do. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/views/Review.tsx | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index bbbb9431..c2bb37ea 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -484,51 +484,62 @@ export function Review({ count={ready.length} label="ready to merge" hint="nudge if idle" + id="review:ready" > - + - + - + - - + + - + - + - + - + {/* deliberately never auto-opened, even on a quiet board — bots stay deprioritized */} Date: Mon, 20 Jul 2026 16:01:39 -0700 Subject: [PATCH 184/381] Document the review-requested chip in the legend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewer-request highlight was undocumented — add it to the row key next to the code-region chip, explaining that it means GitHub asked you to review and that these lead the Requested-of-you section. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Legend.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index c7fb1e73..76d52ba9 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -151,6 +151,14 @@ export function Legend() { } def="matches a code region you set in Settings — these gather in the “In your code regions” section on Review and Team" /> + + review requested + + } + def="GitHub asked you to review this PR — the board leads with these in the “Requested of you” section on Review, and quiets its own turn rotation for them" + /> From 0194ae91610e11f376a8870ce81cf953c7898488 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 16:07:52 -0700 Subject: [PATCH 185/381] Retire the legacy v1 frontend and serve v2 at / MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 has fully replaced v1, and its /v2 path is dead too — collapse to a single frontend served at the root. Server: the two static mounts become one (frontend-v2/dist at /), the history endpoint moves /v2/stats-history -> /stats-history, and the auth gate drops its now-dead /v2 and /v2/ entries. Frontend: Vite's base drops back to / (so BASE_URL and every built asset URL re-root, and the config.json fetch follows), the history fetch and the auth-reload link match the new path, and the header's "v1 board" link is gone. The dummy fixture, the one thing v2 still borrowed from frontend/, moves into src/backend and is lazy-imported so it stays out of the production bundle. Build: postinstall now builds only v2; the v1 webpack/babel/less/chakra toolchain and its dependencies are removed from package.json (571 packages), leaving the server deps plus the eslint/types kept for linting. frontend/ is deleted wholesale — nothing in the server or v2 imported from it once the fixture moved. Co-Authored-By: Claude Opus 4.8 --- .eslintrc.json | 110 +- README.md | 11 +- app.js | 5 +- controllers/stats.js | 245 +- frontend-v2/README.md | 10 +- frontend-v2/src/app.tsx | 9 +- .../src/backend}/dummy-pulls.json | 0 frontend-v2/src/backend/dummy.ts | 8 +- frontend-v2/src/model/statsHistory.ts | 4 +- frontend-v2/src/settings.ts | 2 +- frontend-v2/vite.config.ts | 5 +- frontend/index.html | 15 - frontend/src/backend/dummy-pulls.ts | 59 - frontend/src/backend/pull-socket.ts | 58 - frontend/src/backend/socket.ts | 58 - frontend/src/closed-pulls.tsx | 41 - frontend/src/column.tsx | 49 - frontend/src/filter-menu.tsx | 157 - frontend/src/index.tsx | 19 - frontend/src/leader-list.tsx | 112 - frontend/src/navbar.tsx | 315 - frontend/src/notifications.tsx | 111 - frontend/src/page-context.ts | 29 - frontend/src/pull-card/age.tsx | 28 - frontend/src/pull-card/avatar.tsx | 38 - frontend/src/pull-card/commit-statuses.tsx | 146 - frontend/src/pull-card/copy-branch.tsx | 34 - frontend/src/pull-card/flags.tsx | 135 - frontend/src/pull-card/index.tsx | 221 - frontend/src/pull-card/participants.tsx | 36 - frontend/src/pull-card/refresh.tsx | 59 - frontend/src/pull-card/signatures.tsx | 192 - frontend/src/pull.ts | 270 - .../src/pulldasher/filtered-pulls-state.ts | 44 - frontend/src/pulldasher/index.tsx | 109 - frontend/src/pulldasher/notifications.tsx | 28 - frontend/src/pulldasher/pulls-context.tsx | 86 - frontend/src/pulldasher/pulls-state.tsx | 47 - frontend/src/pulldasher/sort.ts | 69 - frontend/src/theme.tsx | 226 - frontend/src/theme/base.less | 34 - frontend/src/theme/day_theme.less | 184 - frontend/src/theme/night_theme.less | 193 - frontend/src/types.ts | 130 - frontend/src/use-url-state.ts | 118 - frontend/src/utils.ts | 95 - frontend/test/named-pulls.ts | 532 - frontend/test/pull-card-demo.tsx | 86 - frontend/test/pull-data-parts.ts | 145 - frontend/tsconfig.json | 14 - frontend/webpack-define-read-file.js | 13 - frontend/webpack.dev.config.js | 107 - frontend/webpack.prod.config.js | 4 - lib/authentication.js | 204 +- package-lock.json | 19596 +++------------- package.json | 48 +- 56 files changed, 3888 insertions(+), 20815 deletions(-) rename {frontend => frontend-v2/src/backend}/dummy-pulls.json (100%) delete mode 100644 frontend/index.html delete mode 100644 frontend/src/backend/dummy-pulls.ts delete mode 100644 frontend/src/backend/pull-socket.ts delete mode 100644 frontend/src/backend/socket.ts delete mode 100644 frontend/src/closed-pulls.tsx delete mode 100644 frontend/src/column.tsx delete mode 100644 frontend/src/filter-menu.tsx delete mode 100644 frontend/src/index.tsx delete mode 100644 frontend/src/leader-list.tsx delete mode 100644 frontend/src/navbar.tsx delete mode 100644 frontend/src/notifications.tsx delete mode 100644 frontend/src/page-context.ts delete mode 100644 frontend/src/pull-card/age.tsx delete mode 100644 frontend/src/pull-card/avatar.tsx delete mode 100644 frontend/src/pull-card/commit-statuses.tsx delete mode 100644 frontend/src/pull-card/copy-branch.tsx delete mode 100644 frontend/src/pull-card/flags.tsx delete mode 100644 frontend/src/pull-card/index.tsx delete mode 100644 frontend/src/pull-card/participants.tsx delete mode 100644 frontend/src/pull-card/refresh.tsx delete mode 100644 frontend/src/pull-card/signatures.tsx delete mode 100644 frontend/src/pull.ts delete mode 100644 frontend/src/pulldasher/filtered-pulls-state.ts delete mode 100644 frontend/src/pulldasher/index.tsx delete mode 100644 frontend/src/pulldasher/notifications.tsx delete mode 100644 frontend/src/pulldasher/pulls-context.tsx delete mode 100644 frontend/src/pulldasher/pulls-state.tsx delete mode 100644 frontend/src/pulldasher/sort.ts delete mode 100644 frontend/src/theme.tsx delete mode 100644 frontend/src/theme/base.less delete mode 100644 frontend/src/theme/day_theme.less delete mode 100644 frontend/src/theme/night_theme.less delete mode 100644 frontend/src/types.ts delete mode 100644 frontend/src/use-url-state.ts delete mode 100644 frontend/src/utils.ts delete mode 100644 frontend/test/named-pulls.ts delete mode 100644 frontend/test/pull-card-demo.tsx delete mode 100644 frontend/test/pull-data-parts.ts delete mode 100644 frontend/tsconfig.json delete mode 100644 frontend/webpack-define-read-file.js delete mode 100644 frontend/webpack.dev.config.js delete mode 100644 frontend/webpack.prod.config.js diff --git a/.eslintrc.json b/.eslintrc.json index bc144bb6..043c72ee 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,62 +1,52 @@ { - "extends": [ - "eslint:recommended" - ], - "parserOptions": { - "ecmaVersion": 2022, - "sourceType": "module" - }, - "env": { - "browser": true, - "node": true, - "es2022": true - }, - "globals": { - "App": true - }, - "ignorePatterns": [ - "dist/*", - "frontend/dist/*" - ], - "overrides": [ - { - "files": [ - "**/*.ts", - "**/*.tsx" - ], - "parser": "@typescript-eslint/parser", - "plugins": [ - "@typescript-eslint" - ], - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react/jsx-runtime", - "plugin:import/recommended", - "plugin:import/typescript" - ], - "parserOptions": { - "ecmaVersion": 2017, - "sourceType": "module" - }, - "env": { - "browser": true, - "node": true, - "es6": true - }, - "globals": { - "App": true - }, - "rules": { - "react/jsx-uses-react": "off", - "react/react-in-jsx-scope": "off" + "extends": ["eslint:recommended"], + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "env": { + "browser": true, + "node": true, + "es2022": true + }, + "globals": { + "App": true + }, + "ignorePatterns": ["dist/*"], + "overrides": [ + { + "files": ["**/*.ts", "**/*.tsx"], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/jsx-runtime", + "plugin:import/recommended", + "plugin:import/typescript" + ], + "parserOptions": { + "ecmaVersion": 2017, + "sourceType": "module" + }, + "env": { + "browser": true, + "node": true, + "es6": true + }, + "globals": { + "App": true + }, + "rules": { + "react/jsx-uses-react": "off", + "react/react-in-jsx-scope": "off" + } } - } - ], - "settings": { - "react": { - "version": "detect" - } - } -} \ No newline at end of file + ], + "settings": { + "react": { + "version": "detect" + } + } +} diff --git a/README.md b/README.md index 0453b584..0837f5ab 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,9 @@ Pulldasher is released under the [MIT License](LICENSE/). ### React Frontend -- Hack on the just the UI (no DB needed): `npm frontend:start` - - Then open http://localhost:8080/ - - Or the demo page at http://localhost:8080/pull-card-demo.html -- Hack on both the frontend + backend: `npm frontend:watch` + `npm start` - - Then open http://localhost:{port} where port is from your config.js +The frontend lives in `frontend-v2/` (see its README). From the repo root: + +- Hack on just the UI, no DB needed: `npm run frontend:dummy`, then open the + Vite dev server it prints (a synthetic board, no backend). +- Hack on the frontend against a live backend: `npm run frontend:dev` (proxies + `/token`, the socket, and `/stats-history` to a local `npm start`). diff --git a/app.js b/app.js index 43ddd6d7..263172d1 100644 --- a/app.js +++ b/app.js @@ -56,10 +56,9 @@ app.use(function (req, res, next) { * Routes */ authManager.setupRoutes(app); -app.use('/v2', express.static(__dirname + '/frontend-v2/dist')); -app.use('/', express.static(__dirname + '/frontend/dist')); +app.use('/', express.static(__dirname + '/frontend-v2/dist')); app.get('/token', mainController.getToken); -app.get('/v2/stats-history', statsController.getHistory); +app.get('/stats-history', statsController.getHistory); app.post('/hooks/main', hooksController.main); debug('Loading all recent pulls from the DB'); diff --git a/controllers/stats.js b/controllers/stats.js index db15d253..a873528c 100644 --- a/controllers/stats.js +++ b/controllers/stats.js @@ -1,7 +1,7 @@ -import db from "../lib/db.js"; -import debug from "../lib/debug.js"; +import db from '../lib/db.js'; +import debug from '../lib/debug.js'; -const statsDebug = debug("pulldasher:stats"); +const statsDebug = debug('pulldasher:stats'); // This data moves slowly (it's dominated by history, not the live board), so // memoize the whole response for a while rather than re-running four @@ -17,9 +17,9 @@ const WEEK_MS = 7 * DAY_MS; * Opened vs merged pulls per calendar month, last 12 months. */ function queryMonthly() { - const since = Math.floor((Date.now() - 12 * MONTH_MS) / 1000); - const q = - "\ + const since = Math.floor((Date.now() - 12 * MONTH_MS) / 1000); + const q = + "\ SELECT month, SUM(opened) AS opened, SUM(merged) AS merged FROM ( \ SELECT DATE_FORMAT(FROM_UNIXTIME(date), '%Y-%m') AS month, 1 AS opened, 0 AS merged \ FROM pulls WHERE date >= ? \ @@ -29,15 +29,15 @@ function queryMonthly() { ) x \ GROUP BY month ORDER BY month"; - return db.query(q, [since, since]).then(function (rows) { - return rows.map(function (row) { - return { - month: row.month, - opened: Number(row.opened), - merged: Number(row.merged), - }; - }); - }); + return db.query(q, [since, since]).then(function (rows) { + return rows.map(function (row) { + return { + month: row.month, + opened: Number(row.opened), + merged: Number(row.merged), + }; + }); + }); } /** @@ -45,27 +45,27 @@ function queryMonthly() { * days: average and max hours, plus how many merged that day. */ function queryMergeAgeByDay() { - const since = Math.floor((Date.now() - 60 * DAY_MS) / 1000); - const q = - "\ + const since = Math.floor((Date.now() - 60 * DAY_MS) / 1000); + const q = + '\ SELECT DATE(FROM_UNIXTIME(date_merged)) AS day, \ AVG(date_merged - date) AS avg_seconds, \ MAX(date_merged - date) AS max_seconds, \ COUNT(*) AS merged \ FROM pulls \ WHERE date_merged >= ? \ - GROUP BY day ORDER BY day"; - - return db.query(q, [since]).then(function (rows) { - return rows.map(function (row) { - return { - day: formatDate(row.day), - avgHours: round1(row.avg_seconds / 3600), - maxHours: round1(row.max_seconds / 3600), - merged: Number(row.merged), - }; - }); - }); + GROUP BY day ORDER BY day'; + + return db.query(q, [since]).then(function (rows) { + return rows.map(function (row) { + return { + day: formatDate(row.day), + avgHours: round1(row.avg_seconds / 3600), + maxHours: round1(row.max_seconds / 3600), + merged: Number(row.merged), + }; + }); + }); } /** @@ -77,9 +77,9 @@ function queryMergeAgeByDay() { * than a second, uglier query). */ function queryFirstCrLatencies() { - const since = Math.floor((Date.now() - 12 * MONTH_MS) / 1000); - const q = - "\ + const since = Math.floor((Date.now() - 12 * MONTH_MS) / 1000); + const q = + "\ SELECT DATE_FORMAT(FROM_UNIXTIME(p.date_merged), '%Y-%m') AS month, \ (cr.min_date - p.date) AS latency_seconds \ FROM pulls p \ @@ -92,32 +92,32 @@ function queryFirstCrLatencies() { WHERE p.date_merged >= ? \ AND cr.min_date > p.date"; - return db.query(q, [since]); + return db.query(q, [since]); } /** * Average PR duration (open to merge) grouped by ISO week, last 26 weeks. */ function queryDurationByWeek() { - const since = Math.floor((Date.now() - 26 * WEEK_MS) / 1000); - const q = - "\ + const since = Math.floor((Date.now() - 26 * WEEK_MS) / 1000); + const q = + '\ SELECT YEARWEEK(FROM_UNIXTIME(date_merged), 3) AS iso_yearweek, \ AVG(date_merged - date) AS avg_seconds, \ COUNT(*) AS merged \ FROM pulls \ WHERE date_merged >= ? \ - GROUP BY iso_yearweek ORDER BY iso_yearweek"; - - return db.query(q, [since]).then(function (rows) { - return rows.map(function (row) { - return { - isoWeek: formatIsoWeek(row.iso_yearweek), - avgHours: round1(row.avg_seconds / 3600), - merged: Number(row.merged), - }; - }); - }); + GROUP BY iso_yearweek ORDER BY iso_yearweek'; + + return db.query(q, [since]).then(function (rows) { + return rows.map(function (row) { + return { + isoWeek: formatIsoWeek(row.iso_yearweek), + avgHours: round1(row.avg_seconds / 3600), + merged: Number(row.merged), + }; + }); + }); } /** @@ -127,75 +127,80 @@ function queryDurationByWeek() { * computed here instead of in SQL). */ function firstCrByMonth(rows) { - const byMonth = new Map(); - rows.forEach(function (row) { - if (!byMonth.has(row.month)) { - byMonth.set(row.month, []); - } - byMonth.get(row.month).push(Number(row.latency_seconds) / 3600); - }); - - return [...byMonth.keys()].sort().map(function (month) { - const hours = byMonth.get(month); - const avgHours = hours.reduce(function (a, b) { return a + b; }, 0) / hours.length; - return { - month, - avgHours: round1(avgHours), - medianHours: round1(median(hours)), - sampled: hours.length, - }; - }); + const byMonth = new Map(); + rows.forEach(function (row) { + if (!byMonth.has(row.month)) { + byMonth.set(row.month, []); + } + byMonth.get(row.month).push(Number(row.latency_seconds) / 3600); + }); + + return [...byMonth.keys()].sort().map(function (month) { + const hours = byMonth.get(month); + const avgHours = + hours.reduce(function (a, b) { + return a + b; + }, 0) / hours.length; + return { + month, + avgHours: round1(avgHours), + medianHours: round1(median(hours)), + sampled: hours.length, + }; + }); } function median(xs) { - if (!xs.length) return 0; - const sorted = [...xs].sort(function (a, b) { return a - b; }); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; + if (!xs.length) return 0; + const sorted = [...xs].sort(function (a, b) { + return a - b; + }); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } function round1(n) { - return Math.round(n * 10) / 10; + return Math.round(n * 10) / 10; } // mysql2 returns DATE columns as JS Date objects (in the connection's local // time) — format as a plain YYYY-MM-DD so the wire shape is timezone-free. function formatDate(date) { - const d = new Date(date); - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return y + "-" + m + "-" + day; + const d = new Date(date); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return y + '-' + m + '-' + day; } // MySQL's YEARWEEK(..., 3) (ISO mode) returns e.g. 202629 for ISO week 29 of // 2026; split it back into "2026-W29". function formatIsoWeek(yearweek) { - const n = Number(yearweek); - const year = Math.floor(n / 100); - const week = n % 100; - return year + "-W" + String(week).padStart(2, "0"); + const n = Number(yearweek); + const year = Math.floor(n / 100); + const week = n % 100; + return year + '-W' + String(week).padStart(2, '0'); } function loadHistory() { - return Promise.all([ - queryMonthly(), - queryMergeAgeByDay(), - queryFirstCrLatencies(), - queryDurationByWeek(), - ]).then(function (results) { - const monthly = results[0]; - const mergeAgeByDay = results[1]; - const firstCrRows = results[2]; - const durationByWeek = results[3]; - - return { - monthly: monthly, - mergeAgeByDay: mergeAgeByDay, - firstCrByMonth: firstCrByMonth(firstCrRows), - durationByWeek: durationByWeek, - }; - }); + return Promise.all([ + queryMonthly(), + queryMergeAgeByDay(), + queryFirstCrLatencies(), + queryDurationByWeek(), + ]).then(function (results) { + const monthly = results[0]; + const mergeAgeByDay = results[1]; + const firstCrRows = results[2]; + const durationByWeek = results[3]; + + return { + monthly: monthly, + mergeAgeByDay: mergeAgeByDay, + firstCrByMonth: firstCrByMonth(firstCrRows), + durationByWeek: durationByWeek, + }; + }); } // Named exports below are the pure reducers (grouping/median/date-formatting) @@ -205,26 +210,26 @@ function loadHistory() { export { firstCrByMonth, median, formatDate, formatIsoWeek, round1 }; export default { - /** - * GET /v2/stats-history — aggregate PR history (months/weeks/days, not the - * live board) for the frontend-v2 Stats lens trend cards. Auth-gated - * exactly like /token (see lib/authentication.js setupRoutes). Read-only, - * memoized for CACHE_TTL_MS since the underlying data moves slowly. - */ - getHistory: function (req, res) { - if (cache && Date.now() - cache.at < CACHE_TTL_MS) { - statsDebug("getHistory: serving cached response"); - return res.json(cache.body); - } - - loadHistory() - .then(function (body) { - cache = { at: Date.now(), body: body }; - res.json(body); - }) - .catch(function (err) { - console.error("stats-history query failed:", err); - res.status(500).json({ error: "stats query failed" }); - }); - }, + /** + * GET /stats-history — aggregate PR history (months/weeks/days, not the + * live board) for the frontend-v2 Stats lens trend cards. Auth-gated + * exactly like /token (see lib/authentication.js setupRoutes). Read-only, + * memoized for CACHE_TTL_MS since the underlying data moves slowly. + */ + getHistory: function (req, res) { + if (cache && Date.now() - cache.at < CACHE_TTL_MS) { + statsDebug('getHistory: serving cached response'); + return res.json(cache.body); + } + + loadHistory() + .then(function (body) { + cache = { at: Date.now(), body: body }; + res.json(body); + }) + .catch(function (err) { + console.error('stats-history query failed:', err); + res.status(500).json({ error: 'stats query failed' }); + }); + }, }; diff --git a/frontend-v2/README.md b/frontend-v2/README.md index 512e99f9..4227af95 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -1,9 +1,11 @@ # Pulldasher frontend v2 A reviewer-centric frontend that answers "what should I act on next?" instead -of rendering seven flat columns. It runs beside the classic board: the server -serves v1 at `/` and v2 at `/v2`, both fed by the same socket protocol -(`initialize` + `pullChange`), so nothing about the backend or DB changes. +of rendering seven flat columns. It is the only Pulldasher frontend — the +server serves it at `/`, fed by the socket protocol (`initialize` + +`pullChange`), so nothing about the backend or DB changes. (The legacy v1 +board it replaced lives on only as the Classic lens, a faithful port of its +seven-column layout.) ## Ideas @@ -114,7 +116,7 @@ cd frontend-v2 npm install npm run dev # against a local backend on :3000 (proxies /token + socket) npm run dev:dummy # no backend: v1's dummy-pulls.json fixture -npm run build # type-check + bundle to frontend-v2/dist (served at /v2) +npm run build # type-check + bundle to frontend-v2/dist (served at /) npm test # vitest: status, sort, query, and legacy-URL unit tests ``` diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 2aac4a9c..0c8a8b62 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -810,13 +810,6 @@ export function App() { open - - v1 board - Sign-in failed. Your session may have expired.{' '} - + Reload to sign in again . diff --git a/frontend/dummy-pulls.json b/frontend-v2/src/backend/dummy-pulls.json similarity index 100% rename from frontend/dummy-pulls.json rename to frontend-v2/src/backend/dummy-pulls.json diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts index e858591b..40e8a50a 100644 --- a/frontend-v2/src/backend/dummy.ts +++ b/frontend-v2/src/backend/dummy.ts @@ -3,8 +3,9 @@ import type { InitializePayload, PullData } from '../types'; /** * Dummy mode: run the whole UI without a backend. * npm run dev:dummy - * Reuses v1's fixture (frontend/dummy-pulls.json) so both frontends stay - * honest against the same wire shape. + * The fixture is a frozen snapshot of the wire shape (it moved here when the + * legacy v1 frontend was retired), lazy-imported so it never lands in the + * production bundle. */ export function isDummy(): boolean { return import.meta.env.VITE_DUMMY === '1'; @@ -15,8 +16,7 @@ export function dummyUser(): string { } export async function loadDummy(): Promise { - const raw = (await import('../../../frontend/dummy-pulls.json')) - .default as unknown as PullData[]; + const raw = (await import('./dummy-pulls.json')).default as unknown as PullData[]; // The fixture is a decade of frozen pulls; re-date them so age-derived // signals (heat, starvation, freshness) exercise realistically. const pulls = withSyntheticStacks(raw).map((p, i) => redate(p, i)); diff --git a/frontend-v2/src/model/statsHistory.ts b/frontend-v2/src/model/statsHistory.ts index 5fac32eb..e1dc2764 100644 --- a/frontend-v2/src/model/statsHistory.ts +++ b/frontend-v2/src/model/statsHistory.ts @@ -4,7 +4,7 @@ import { isDummy } from '../backend/dummy'; /** * The Stats lens's history layer: months/weeks of PR history the live socket * payload doesn't carry (it only ships open pulls + 14 days of closed ones). - * Backed by GET /v2/stats-history, a read-only DB aggregate the server + * Backed by GET /stats-history, a read-only DB aggregate the server * memoizes for 10 minutes since this data moves slowly. Fetched once per * session (module-level cache) and hidden entirely — never a loading spinner * or error banner — on any failure, so a stale or unreachable endpoint just @@ -317,7 +317,7 @@ let cache: Promise | null = null; function load(): Promise { if (isDummy()) return Promise.resolve(dummyHistory()); - return fetch('/v2/stats-history') + return fetch('/stats-history') .then(r => (r.ok ? (r.json() as Promise) : null)) .catch(() => null); } diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index dc91370f..11954f82 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -12,7 +12,7 @@ export interface Settings { theme: 'system' | 'light' | 'dark'; /** row height: comfortable is the default, compact packs more on screen */ density: 'comfortable' | 'compact'; - /** which lens a bare /v2/ URL opens */ + /** which lens a bare / URL opens */ defaultLens: string; /** a PR's age turns amber at this many days (display only) */ ageWarnDays: number; diff --git a/frontend-v2/vite.config.ts b/frontend-v2/vite.config.ts index b52fc7bd..c1b3f5a3 100644 --- a/frontend-v2/vite.config.ts +++ b/frontend-v2/vite.config.ts @@ -2,16 +2,15 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; -// Served by the express app at /v2 (see app.js); the dev server proxies +// Served by the express app at / (see app.js); the dev server proxies // /token and the socket back to a locally running pulldasher backend. export default defineConfig({ - base: '/v2/', plugins: [react(), tailwindcss()], server: { proxy: { '/token': 'http://localhost:3000', '/socket.io': { target: 'http://localhost:3000', ws: true }, - '/v2/stats-history': 'http://localhost:3000', + '/stats-history': 'http://localhost:3000', }, }, }); diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 00b19d77..00000000 --- a/frontend/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - diff --git a/frontend/src/backend/dummy-pulls.ts b/frontend/src/backend/dummy-pulls.ts deleted file mode 100644 index 9d6d4ef5..00000000 --- a/frontend/src/backend/dummy-pulls.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Pull } from "../pull"; -import { PullData, Signature, SignatureType, CommentSource } from "../types"; - -const COMMENT_ID = 13371337; - -function addSynthReview(pull: Pull) { - const signed: PullData = { - ...pull, - status: { - ...pull.status, - allQA: [...pull.status.allQA, getFakeSig(pull.number, SignatureType.QA)], - allCR: [...pull.status.allCR, getFakeSig(pull.number, SignatureType.CR)], - }, - }; - return new Pull(signed); -} - -function removeSynthReview(pull: Pull) { - const unsigned: PullData = { - ...pull, - status: { - ...pull.status, - allQA: pull.status.allQA.filter((sig) => !isSynthComment(sig)), - allCR: pull.status.allCR.filter((sig) => !isSynthComment(sig)), - }, - }; - return new Pull(unsigned); -} - -export function toggleSynthReview(pull: Pull) { - const isFaked = pull.status.allQA.find(isSynthComment); - if (isFaked) { - return removeSynthReview(pull); - } else { - return addSynthReview(pull); - } -} - -function getFakeSig(number: number, type: SignatureType) { - return { - data: { - repo: "iFixit/ifixit", - number, - user: { - id: 13455801, - login: "danielbeardsley", - }, - type, - created_at: "1970-11-16T20:06:15.000Z", - active: 1, - comment_id: COMMENT_ID, - source_type: CommentSource.review, - }, - }; -} - -function isSynthComment(sig: Signature) { - return sig.data.comment_id === COMMENT_ID; -} diff --git a/frontend/src/backend/pull-socket.ts b/frontend/src/backend/pull-socket.ts deleted file mode 100644 index 1304657f..00000000 --- a/frontend/src/backend/pull-socket.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Pull } from "../pull"; -import { getSocket } from "./socket"; -import { PullData, RepoSpec } from "../types"; -import { dummyPulls } from "../utils"; -import { toggleSynthReview } from "./dummy-pulls"; - -type PullUpdater = (pullDatas: PullData[], repoSpecs: RepoSpec[]) => void; -let repoSpecs: RepoSpec[] = []; - -/** - * connects to the backend and calls the callback each time we receive - * pullChange events from the server. - */ -function pullSocket(pullsUpdated: PullUpdater) { - const socket = getSocket(); - socket.on( - "initialize", - function (data: { repos: RepoSpec[]; pulls: PullData[] }) { - repoSpecs = data.repos; - pullsUpdated(data.pulls, repoSpecs); - } - ); - - socket.on("pullChange", function (pull: PullData) { - pullsUpdated([pull], repoSpecs); - }); -} - -function sendRefreshPull(pull: Pull) { - const socket = getSocket(); - socket.emit("refresh", pull.repo, pull.number); -} - -/************** - * When we are faking the backend with a dummy array of pulls, - * we need to fake other backend interactions (like refresh) - */ - -let pullsUpdatedHandler: PullUpdater | null; -function mockPullSocket(pullsUpdated: PullUpdater) { - pullsUpdatedHandler = pullsUpdated; - return pullsUpdated(dummyPulls, repoSpecs); -} - -export function mockRefreshPull(pull: Pull) { - // Pretend the pull is updated from the server-side a bit later. - setTimeout(() => { - if (pullsUpdatedHandler) { - const updatedPull = toggleSynthReview(pull); - pullsUpdatedHandler([updatedPull], []); - } - }, 2000); -} - -export const createPullSocket = dummyPulls.length ? mockPullSocket : pullSocket; -export const refreshPull = dummyPulls.length - ? mockRefreshPull - : sendRefreshPull; diff --git a/frontend/src/backend/socket.ts b/frontend/src/backend/socket.ts deleted file mode 100644 index a256bb4c..00000000 --- a/frontend/src/backend/socket.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { getPageContext } from "../page-context"; -import { io, Socket } from "socket.io-client"; -import { useState, useEffect } from "react"; -import { hasDummyPulls } from "../utils"; - -type SocketIO = Socket; - -let socket: SocketIO; - -function createSocket(): SocketIO { - socket = io(); - - socket.on("connect", function () { - getPageContext().then((details) => { - socket.emit("authenticate", details.socketToken); - }); - }); - return socket; -} - -export function getSocket() { - return (socket = socket || createSocket()); -} - -export enum ConnectionState { - disconnected = "disconnected", - connected = "connected", - connecting = "connecting", - error = "error", -} - -export function useConnectionState(): ConnectionState { - const [state, setState] = useState( - ConnectionState.connecting - ); - if (hasDummyPulls()) { - return ConnectionState.connected; - } - useEffect(() => listenForConnectionEvents(setState), [getSocket()]); - return state; -} - -function listenForConnectionEvents(setState: (state: ConnectionState) => void) { - const onConnect = () => setState(ConnectionState.connected); - const onDisconnect = () => setState(ConnectionState.disconnected); - const onReconnectAttempt = () => setState(ConnectionState.connecting); - const socket = getSocket(); - socket.on("connect", onConnect); - socket.on("disconnect", onDisconnect); - socket.io.on("reconnect_attempt", onReconnectAttempt); - socket.io.engine.on("close", onDisconnect); - return () => { - socket.off("connect", onConnect); - socket.off("disconnect", onDisconnect); - socket.io.off("reconnect_attempt", onReconnectAttempt); - socket.io.engine.off("close", onDisconnect); - }; -} diff --git a/frontend/src/closed-pulls.tsx b/frontend/src/closed-pulls.tsx deleted file mode 100644 index 2a2665f1..00000000 --- a/frontend/src/closed-pulls.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useFilteredPulls } from "./pulldasher/pulls-context"; -import { useStyleConfig, Flex, Spacer, Box } from "@chakra-ui/react"; -import { ClosedPullCard } from "./pull-card"; -import { closedAtCompare } from "./pulldasher/sort"; -import { useMemo } from "react"; - -export function ClosedPulls({ onClickClose }: { onClickClose: () => void }) { - const filteredPulls = useFilteredPulls(); - const closedPulls = useMemo(() => { - const pulls = filteredPulls.filter((pull) => pull.closed_at); - pulls.sort(closedAtCompare); - return pulls; - }, [filteredPulls]); - - const styles = useStyleConfig("Column", { variant: "closed" }); - return ( - - - - Recently Closed Pulls - - - - {closedPulls.length} - - - {closedPulls.map((pull) => ( - - ))} - - ); -} diff --git a/frontend/src/column.tsx b/frontend/src/column.tsx deleted file mode 100644 index 9b95b095..00000000 --- a/frontend/src/column.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Pull } from "./pull"; -import { PullCard } from "./pull-card"; -import { useBoolUrlState } from "./use-url-state"; -import { useFilteredOpenPulls } from "./pulldasher/pulls-context"; -import { Box, Flex, Spacer, useStyleConfig } from "@chakra-ui/react"; - -interface ColumnProps { - variant?: string; - pulls: Pull[]; - title: string; - id: string; - showLinesChanged?: boolean; -} - -export function Column(props: ColumnProps) { - const pullsToShow = useFilteredOpenPulls(); - const [open, toggleOpen] = useBoolUrlState(props.id, true); - const styles = useStyleConfig("Column", { variant: props.variant }); - return ( - - - - {props.title} - - - - {countPulls(props.pulls, pullsToShow)} - - - - {props.pulls.map((pull) => ( - - ))} - - - ); -} - -function countPulls(pulls: Pull[], pullsToShow: Set): number { - return pulls.reduce( - (count, pull) => count + (pullsToShow.has(pull) ? 1 : 0), - 0 - ); -} diff --git a/frontend/src/filter-menu.tsx b/frontend/src/filter-menu.tsx deleted file mode 100644 index 53d6bf67..00000000 --- a/frontend/src/filter-menu.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useAllOpenPulls, useSetFilter } from "./pulldasher/pulls-context"; -import { useArrayUrlState } from "./use-url-state"; -import { Pull } from "./pull"; -import { - useConst, - Button, - Menu, - MenuButton, - MenuList, - MenuDivider, - MenuOptionGroup, - MenuItemOption, - chakra, -} from "@chakra-ui/react"; -import { useEffect, useMemo } from "react"; -import { countBy } from "lodash-es"; - -// Map from value to number of pulls that have that value -type ValueGetter = (pull: Pull) => string; - -const SHOWALL = "SHOWALL"; - -type FilterMenuProps = { - urlParam: string; - buttonText: string; - extractValueFromPull: ValueGetter; - defaultExculdedValues?: string[]; -}; - -export function FilterMenu({ - urlParam, - buttonText, - extractValueFromPull, - defaultExculdedValues, -}: FilterMenuProps) { - const pulls = useAllOpenPulls(); - // Default is empty array that implies show all pulls (no filtering) - const [selectedValues, setSelectedValues] = useArrayUrlState(urlParam, []); - // Nothing selected == show the default values (everything except the excluded values) - const showDefault = selectedValues.length === 0; - // Show every single value if the magic SHOWALL string is selected - const showAll = notEmpty(defaultExculdedValues) ? selectedValues.includes(SHOWALL) : selectedValues.length === 0; - - // List from url may contain values we have no pulls for - const urlValues = useConst(() => new Set(selectedValues)); - const setPullFilter = useSetFilter(); - - // May include values from the url for which there are no pulls - const allValues = useMemo(() => { - // All values of open pulls - const pullValues = new Set(pulls.map(extractValueFromPull)); - const allValuesSet = new Set([...pullValues, ...urlValues]); - allValuesSet.delete(SHOWALL); - return sortValues([...allValuesSet]); - }, [pulls]); - - const valueToPullCount = useMemo( - () => countBy(pulls, extractValueFromPull), - [pulls] - ); - - const defaultSelectedValues = arrayDiff(allValues, defaultExculdedValues || []); - - useEffect(() => { - const selectedValuesSet = new Set(selectedValues); - setPullFilter( - urlParam, - showAll - ? null - : (showDefault ? (pull) => !defaultExculdedValues?.includes(extractValueFromPull(pull)) - : (pull) => selectedValuesSet.has(extractValueFromPull(pull))) - ); - }, [selectedValues, defaultExculdedValues]); - - const numberText = showDefault ? "" : (showAll ? allValues.length : selectedValues.length); - - return ( - - - {buttonText} {numberText ? `(${numberText})` : ""} - - - {notEmpty(defaultExculdedValues) && ( - <> - setSelectedValues([SHOWALL])} - > - Show All - - setSelectedValues([])} - > - Show Default - - ) - } - {empty(defaultExculdedValues) && - setSelectedValues([])} - > - Show All - - } - - - {allValues.map((value) => ( - - {value} ({valueToPullCount[value] || 0}) - { - setSelectedValues([value]); - e.stopPropagation(); - } - }>only - - ))} - - - - ); -} - -function sortValues(values: string[]): string[] { - return values.sort((a: string, b: string) => - a.localeCompare(b, undefined, { sensitivity: "base" }) - ); -} - -function arrayDiff(a: T[], b: T[]): T[] { - return a.filter((x) => !b.includes(x)); -} - -function empty(array: T[] | undefined): boolean { - return !array || array.length === 0; -} - -function notEmpty(array: T[] | undefined): boolean { - return !!array && array.length > 0; -} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx deleted file mode 100644 index cf0edf6e..00000000 --- a/frontend/src/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { render } from "react-dom"; -import { Page } from "./pulldasher"; -import { PullsProvider } from "./pulldasher/pulls-context"; -import { ChakraProvider } from "@chakra-ui/react"; -import { theme } from "./theme"; - -const root = document.createElement("div"); -document.body.appendChild(root); - -function App() { - return ( - - - - - - ); -} -render(, root); diff --git a/frontend/src/leader-list.tsx b/frontend/src/leader-list.tsx deleted file mode 100644 index 6afdb1e7..00000000 --- a/frontend/src/leader-list.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Pull } from "./pull"; -import { toDateString, userProfileUrl } from "./utils"; -import { Signature, SignatureUser } from "./types"; -import { Link, Flex, HStack, Box, chakra } from "@chakra-ui/react"; - -interface SigCount { - user: SignatureUser; - count: number; -} - -const DAYS_TO_EXAMINE = 14; - -export function LeaderList({ - leaders, - title, -}: { - leaders: SigCount[]; - title: string; -}) { - return ( - - - - {title}: - - {leaders.map(({ user, count }, i) => ( - - - - - - {count} - - - ))} - - - ); -} - -function Avatar({ user }: { user: SignatureUser }) { - return ( - - ); -} - -function colorForIndex(index: number): string { - return index < 2 - ? "var(--leaderboard-leader-background)" - : "var(--leaderboard-posting-background)"; -} - -export function getLeaders( - pulls: Pull[], - extractSigsFromPull: (pull: Pull) => Signature[] -) { - const recent = toDateString( - new Date(Date.now() - 86400 * 1000 * DAYS_TO_EXAMINE) - ); - const recentPulls = pulls.filter((pull) => { - return pull.closed_at ? pull.closed_at > recent : true; - }); - const sigCounts = new Map(); - recentPulls.forEach((pull) => { - const sigs: Signature[] = extractSigsFromPull(pull); - const recentSigs = sigs.filter((sig) => sig.data.created_at > recent); - // Count one sig per user from this pull - const seenUsers = new Set(); - recentSigs.forEach((sig) => { - const user = sig.data.user; - if (seenUsers.has(user.id)) { - return; - } - seenUsers.add(user.id); - const sigCount = sigCounts.get(user.id) || { user, count: 0 }; - sigCount.count++; - sigCounts.set(user.id, sigCount); - }); - }); - return Array.from(sigCounts.values()).sort( - (a: SigCount, b: SigCount) => b.count - a.count - ); -} diff --git a/frontend/src/navbar.tsx b/frontend/src/navbar.tsx deleted file mode 100644 index 8f31403e..00000000 --- a/frontend/src/navbar.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { FilterMenu } from "./filter-menu"; -import { getTitle } from "./page-context"; -import { - useFilteredOpenPulls, - useAllOpenPulls, - useSetFilter, - useRepoSpecs, -} from "./pulldasher/pulls-context"; -import { Pull } from "./pull"; -import { - useColorMode, - Button, - HStack, - Center, - Flex, - Box, - BoxProps, - Input, - Menu, - MenuButton, - MenuItemOption, - MenuList, - Text -} from "@chakra-ui/react"; -import { useRef, useEffect, useCallback, useMemo } from "react"; -import { useBoolUrlState } from "./use-url-state"; -import { NotificationRequest } from "./notifications"; -import { useConnectionState, ConnectionState } from "./backend/socket"; -import { useHotkeys } from "react-hotkeys-hook"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - faMoon, - faCodeMerge, - faWifi, - faCircleNotch, - faXmark, - faCircleExclamation, - faUser, - faUsers -} from "@fortawesome/free-solid-svg-icons"; - -// Default width of the left and right sections of the nav bar -const sideWidth = "220px"; - -type NavBarProps = BoxProps & { - toggleShowClosedPulls: () => void; - showClosedPulls: boolean; -}; - -export function Navbar(props: NavBarProps) { - const { toggleShowClosedPulls, showClosedPulls, ...boxProps } = props; - const pulls: Set = useFilteredOpenPulls(); - const allOpenPulls: Pull[] = useAllOpenPulls(); - const setPullFilter = useSetFilter(); - const repoSpecs = useRepoSpecs(); - const reposToHide = useMemo(() => - repoSpecs.filter((repo) => repo.hideByDefault).map((repo) => repo.name.replace(/.*\//g, "")), - [repoSpecs]); - const { toggleColorMode } = useColorMode(); - const [showCryo, toggleShowCryo] = useBoolUrlState("cryo", false); - const [showExtBlocked, toggleShowExtBlocked] = useBoolUrlState( - "external_block", - true - ); - const [showDrafts, toggleShowDrafts] = useBoolUrlState("drafts", false); - const [showPersonalView , togglePersonalView] = useBoolUrlState("personal", false); - const hideBelowMedium = ["none", "none", "block"]; - const hideBelowLarge = ["none", "none", "none", "block"]; - - useEffect( - () => setPullFilter("cryo", showCryo ? null : isNotCryogenic), - [showCryo] - ); - useEffect( - () => - setPullFilter( - "external_block", - showExtBlocked ? null : isNotExternallyBlocked - ), - [showExtBlocked] - ); - useEffect( - () => setPullFilter("drafts", showDrafts ? null : isNotDraftOrMine), - [showDrafts] - ); - useEffect( - () => setPullFilter("personal", showPersonalView ? isMineViaAffiliation : null), - [showPersonalView] - ); - // Set the page title - const title = getTitle(); - useEffect(() => { document.title = title || ''; }, [title]); - - return ( -
- - - - - - - - - Label - - - - Cryogenic Storage - - - External Block - - - Drafts - - - - - pull.getRepoName()} - /> - - - pull.user.login} - /> - - - - {title} - - - - - - - - - - open: - - - {pulls.size} - - - - - v2 - - - - - -
- ); -} - -function isNotCryogenic(pull: Pull): boolean { - return !pull.getLabel("Cryogenic Storage"); -} - -function isNotExternallyBlocked(pull: Pull): boolean { - return !pull.getLabel("external_block"); -} - -function isNotDraftOrMine(pull: Pull): boolean { - return !pull.isDraft() || pull.isMine(); -} - -function isMineViaAffiliation(pull: Pull): boolean { - return pull.isMineViaAffiliation(); -} - -function SearchInput() { - const setPullFilter = useSetFilter(); - const searchInputRef = useRef(null); - const updateSearchFilter = useCallback( - (event: React.ChangeEvent) => { - const patterns = event.target.value - .trim() - .split(/\s+/) - .filter((s) => s.length) - .map((s) => new RegExp(s, "i")); - setPullFilter( - "search", - patterns.length - ? (pull: Pull) => { - return patterns.every((pattern) => pull.title.match(pattern) || pull.repo.match(pattern)); - } - : null - ); - }, - [] - ); - - useHotkeys("/", (event) => { - if (searchInputRef.current) { - searchInputRef.current.focus(); - searchInputRef.current.select(); - event.preventDefault(); - } - }); - - return ( - - ); -} - -function ConnectionStateIndicator() { - const connectionState = useConnectionState(); - if (connectionState == ConnectionState.connected) { - return ; - } - if (connectionState == ConnectionState.connecting) { - return ( - - ); - } - if (connectionState == ConnectionState.disconnected) { - return ; - } - if (connectionState == ConnectionState.error) { - return ; - } - return null; -} diff --git a/frontend/src/notifications.tsx b/frontend/src/notifications.tsx deleted file mode 100644 index 41b03dd6..00000000 --- a/frontend/src/notifications.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import * as React from "react"; -import { Button } from "@chakra-ui/react"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - faBellSlash, - faVolumeHigh, - faVolumeXmark, -} from "@fortawesome/free-solid-svg-icons"; -import { isEqual } from "lodash-es"; - -type KeyType = number | string; - -type NotificationOptions = { - filter: (x: T) => boolean; - message: (xs: T[]) => string; - key: (x: T) => KeyType; -}; - -const notificationsSupported = "Notification" in window; - -function isBellActive() { - return window.localStorage.getItem("bell") === "ring"; -} - -export function useNotification( - xs: T[], - { filter, message, key }: NotificationOptions -) { - if (!notificationsSupported) { - return; - } - const [seen, setSeen] = React.useState([]); - const filtered = xs.filter(filter); - const unseen = filtered.filter((x) => !seen.includes(key(x))); - React.useEffect(() => { - if (unseen.length > 0) { - const msg = message(unseen); - if (Notification.permission === "granted") { - new Notification(msg); - if (isBellActive()) { - new Audio("/public/sounds/bell.mp3").play(); - } - } - } - const proposed = filtered.map(key); - - if (!isEqual(seen, proposed)) { - setSeen(proposed); - } - }); -} - -export function NotificationRequest() { - const [bellActive, setBellActive] = React.useState(isBellActive()); - - const activateBell = () => { - window.localStorage.setItem("bell", "ring"); - setBellActive(true); - }; - - const deactivateBell = () => { - window.localStorage.removeItem("bell"); - setBellActive(false); - }; - - const activateNotifications = () => { - Notification.requestPermission(); - }; - - if (!notificationsSupported) { - return null; - } - if (Notification.permission === "default") { - return ( - - ); - } - - if (bellActive) { - return ( - - ); - } - return ( - - ); -} diff --git a/frontend/src/page-context.ts b/frontend/src/page-context.ts deleted file mode 100644 index dfd481e2..00000000 --- a/frontend/src/page-context.ts +++ /dev/null @@ -1,29 +0,0 @@ -interface tokenResponse { - socketToken: string; - user: string; - title: string; -} - -let pageContext: tokenResponse; - -if (process.env.DUMMY_USER) { - pageContext = { - user: process.env.DUMMY_USER, - socketToken: "fake-token", - title: "Pulldasher", - }; -} - -export function getUser() { - return pageContext && pageContext.user; -} - -export function getTitle() { - return pageContext && pageContext.title; -} - -export function getPageContext(): Promise { - return fetch("/token") - .then((response) => response.json()) - .then((response) => (pageContext = response)); -} diff --git a/frontend/src/pull-card/age.tsx b/frontend/src/pull-card/age.tsx deleted file mode 100644 index e9fa10b9..00000000 --- a/frontend/src/pull-card/age.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { DateString } from "../types"; -import { Box } from "@chakra-ui/react"; - -export function Age({ created_at }: { created_at: DateString }) { - const createdAt = new Date(created_at); - const timeDifference = Date.now() - createdAt.getTime(); - const daysSinceCreation = Math.ceil(timeDifference / (1000 * 3600 * 24)); - const numDots = Math.min(daysSinceCreation, 30); - const dot = "•"; - const tooltip = `opened at ${createdAt.toLocaleString()}`; - - return ( - - {dot.repeat(numDots)} - - ); -} - -function getAgeColor(days: number): string { - switch (Math.ceil(days / 4)) { - case 1: - return "--age-1"; - case 2: - return "--age-2"; - default: - return "--age-3"; - } -} diff --git a/frontend/src/pull-card/avatar.tsx b/frontend/src/pull-card/avatar.tsx deleted file mode 100644 index 1cfc7f89..00000000 --- a/frontend/src/pull-card/avatar.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Img } from "@chakra-ui/react"; -import { userProfileUrl } from "../utils"; - -export function Avatar({ - user, - linkToProfile, - title, -}: { - user: string; - linkToProfile?: boolean; - title?: string; -}) { - const cleanUsername = user.replace(/\[bot\]$/, ""); - return ( - - ); -} - -function avatarClickHandler(event: React.MouseEvent) { - const user: string | undefined = event.currentTarget?.dataset.user; - if (!user) { - return; - } - window.open(userProfileUrl(user), "_blank"); - event.preventDefault(); -} diff --git a/frontend/src/pull-card/commit-statuses.tsx b/frontend/src/pull-card/commit-statuses.tsx deleted file mode 100644 index 60098421..00000000 --- a/frontend/src/pull-card/commit-statuses.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { Pull } from "../pull"; -import { CommitStatus, StatusState } from "../types"; -import { newTab, useDurationMinutes } from "../utils"; -import { - chakra, - Box, - useStyleConfig, - Popover, - PopoverTrigger, - PopoverContent, - PopoverBody, - PopoverArrow, - PopoverCloseButton, - Portal, - BoxProps, -} from "@chakra-ui/react"; -import { groupBy } from "lodash-es"; -import { memo } from "react"; -import styled from "@emotion/styled"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - faCircleExclamation, - faCircleXmark, - faCircleCheck, - faClock, - IconDefinition, -} from "@fortawesome/free-solid-svg-icons"; - -const statusSize = 10; -const marginBetween = 4; - -const StatusContainer = styled.div` - position: absolute; - top: 0; - left: 0; - bottom: 0; - margin: 6px; - width: ${statusSize}px; - display: flex; - flex-shrink: 0; - flex-direction: column; - justify-content: space-between; - gap: ${marginBetween}px; - cursor: pointer; -`; - -const StatusLinkContainer = Box; - -function StatusLink({ status }: { status: CommitStatus }) { - const styles = useStyleConfig("StatusLink", { variant: status.data.state }); - const link = status.data.target_url; - const duration = useDurationMinutes(status); - return ( - newTab(link) : undefined} - > - - {status.data.context}: - {status.data.description} - {duration && {Math.ceil(duration) + "m"}} - - ); -} - -function iconForStatus(status: CommitStatus): IconDefinition { - const state = status.data.state; - return state === StatusState.pending - ? faClock - : state === StatusState.error - ? faCircleExclamation - : state === StatusState.failure - ? faCircleXmark - : faCircleCheck; -} - -type StatusGroupProps = BoxProps & { - statuses: CommitStatus[]; -}; - -function StatusGroup({ statuses, ...props }: StatusGroupProps) { - const state = statuses[0].data.state; - const styles = useStyleConfig("StatusGroup", { variant: state }); - const contexts = statuses.map((status) => status.data.context).join("\n"); - const title = `${state}:${statuses.length > 1 ? "\n" : " "}${contexts}`; - return ( - - ); -} - -function SingleStatus(status: CommitStatus) { - const link = status.data.target_url; - return ( - - newTab(link) : undefined} - statuses={[status]} - /> - - ); -} - -export const CommitStatuses = memo(function CommitStatuses({ - pull, -}: { - pull: Pull; -}) { - const statuses = pull.buildStatusesWithRequired(); - if (statuses.length === 0) { - return null; - } else if (statuses.length === 1) { - return SingleStatus(statuses[0]); - } - const grouped = groupBy(statuses, (status) => status.data.state); - return ( - - - - {grouped.success && } - {grouped.pending && } - {grouped.failure && } - {grouped.error && } - - - - - - - - {statuses.map((status) => ( - - ))} - - - - - ); -}); diff --git a/frontend/src/pull-card/copy-branch.tsx b/frontend/src/pull-card/copy-branch.tsx deleted file mode 100644 index 8d098eac..00000000 --- a/frontend/src/pull-card/copy-branch.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { memo } from "react"; -import { useClipboard, chakra } from "@chakra-ui/react"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCopy, faCheck } from "@fortawesome/free-solid-svg-icons"; - -const CopyBranchIcon = chakra(FontAwesomeIcon, { - baseStyle: { - marginLeft: "10px", - cursor: "pointer", - color: "var(--copy-branch)", - "&:hover": { - color: "var(--copy-branch-hover)", - }, - }, -}); - -export const CopyBranch = memo(function CopyBranch({ - className, - value, -}: { - className: string; - value: string; -}) { - const { onCopy, hasCopied } = useClipboard(value); - return ( - - ); -}); diff --git a/frontend/src/pull-card/flags.tsx b/frontend/src/pull-card/flags.tsx deleted file mode 100644 index 333f630a..00000000 --- a/frontend/src/pull-card/flags.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { Pull } from "../pull"; -import { actionMessage } from "../utils"; -import { Link, Box, useStyleConfig } from "@chakra-ui/react"; -import { memo } from "react"; -import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - faWarning, - faMinusCircle, - faEye, - faEyeSlash, - faSnowflake, - faSpinner, - faCodeCompare, - faCodeBranch, -} from "@fortawesome/free-solid-svg-icons"; - -export const Flags = memo(function Flags({ pull }: { pull: Pull }) { - const devBlock = pull.getDevBlock(); - const draftBlock = pull.isDraft(); - const readyButNoCI = pull.isReady() && !pull.isCiRequired(); - const dependentPR = pull.isDependent(); - const deployBlock = pull.getDeployBlock(); - const QAing = pull.getLabel("QAing"); - const externalBlock = pull.getLabel("external_block"); - const cryogenicStorage = pull.getLabel("Cryogenic Storage"); - return ( - <> - {deployBlock && ( - - )} - {readyButNoCI && ( - - )} - {dependentPR && ( - - )} - {devBlock && ( - - )} - {draftBlock && ( - - )} - {QAing && ( - - )} - {externalBlock && ( - - )} - {cryogenicStorage && ( - - )} - {pull.hasMergeConflicts() && ( - - )} - - ); -}); - -interface PullFlagProps { - href?: string; - variant: string; - title?: string; - icon: IconDefinition; -} - -function PullFlag({ variant, title, href, icon }: PullFlagProps) { - const styles = useStyleConfig("PullFlag", { variant: variant }); - return href ? ( - - - - ) : ( - - - - ); -} diff --git a/frontend/src/pull-card/index.tsx b/frontend/src/pull-card/index.tsx deleted file mode 100644 index 64c906d7..00000000 --- a/frontend/src/pull-card/index.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { Pull } from "../pull"; -import { CommitStatuses } from "./commit-statuses"; -import { Participants } from "./participants"; -import { Age } from "./age"; -import { Flags } from "./flags"; -import { Avatar } from "./avatar"; -import { Signatures } from "./signatures"; -import { CopyBranch } from "./copy-branch"; -import { memo, useEffect, useRef, RefObject } from "react"; -import { RefreshButton } from "./refresh"; -import { Flex, Box, Link, chakra, Text } from "@chakra-ui/react"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - faStar, - faCodeMerge, - faXmark, -} from "@fortawesome/free-solid-svg-icons"; - -const Card = chakra(Flex, { - baseStyle: { - position: "relative", - p: 4, - pl: 6, - borderTop: "1px var(--pull-separator) solid", - "&:hover .build_status": { - opacity: 1, - }, - "& .copy": { - visibility: "hidden", - }, - "&:hover .copy": { - visibility: "visible", - }, - "&:hover .refresh": { - visibility: "visible", - }, - "& .star": { - marginRight: "0.5em", - }, - "&.highlight": { - animation: "highlightPull 2s", - }, - }, -}); - -const SigsAndFlags = chakra(Flex, { - baseStyle: { - mt: 3, - "& > *": { - marginRight: "5px", - marginBottom: "5px", - }, - }, -}); - -export const PullCard = memo(function PullCard({ - pull, - show, - showLinesChanged, -}: { - pull: Pull; - show: boolean; - showLinesChanged?: boolean; -}) { - const cardRef = useRef(null); - highlightOnChange(cardRef, [pull.received_at]); - - const linesAvailable = (pull.additions != null) && (pull.deletions != null); - - return ( - - - - - - - {pull.authoredByMe() ? ( - - ) : ( - - )} - - {pull.getRepoName()} #{pull.number}:{" "} - - {pull.title} - - {showLinesChanged && linesAvailable && - <> - - +{pull.additions} - - - -{pull.deletions} - - - } - - - - - - - - - - ); -}); - -export const ClosedPullCard = memo(function ClosedPullCard({ - pull, - show, -}: { - pull: Pull; - show: boolean; -}) { - const wasMerged = !!pull.merged_at; - return ( - - - - - - {wasMerged ? ( - - ) : ( - - )} - - - {formatDate(pull.closed_at)} - - - - {pull.authoredByMe() ? ( - - ) : ( - - )} - - {pull.getRepoName()} #{pull.number}:{" "} - - {pull.title} - - - - ); -}); - -const formatter = new Intl.DateTimeFormat(undefined, { - dateStyle: "short", - timeStyle: "short", -}); - -const formatDate = (dateStr: string | null) => { - return dateStr ? formatter.format(new Date(dateStr)) : null; -}; - -function highlightOnChange( - ref: RefObject, -// eslint-disable-next-line @typescript-eslint/no-explicit-any - dependencies: Array -) { - // Animate a highlight when pull.received_at changes - useEffect(() => { - ref.current?.classList.add("highlight"); - // 1s after the animation, remove the class - setTimeout(() => ref.current?.classList.remove("highlight"), 3000); - }, dependencies); -} diff --git a/frontend/src/pull-card/participants.tsx b/frontend/src/pull-card/participants.tsx deleted file mode 100644 index c9dff32f..00000000 --- a/frontend/src/pull-card/participants.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Pull } from "../pull"; -import { - Box -} from "@chakra-ui/react"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - faUser, - faUsers, -} from "@fortawesome/free-solid-svg-icons"; - -export function Participants({ pull }: { pull: Pull }) { - if (!pull.participants?.length) { - return null; - } - return ( - - 1 ? faUsers : faUser} - title={tooltip(pull)} - color={pull.participating() ? "var(--participants-including-me)" : "var(--participants-without-me)"} - /> - - ); -} - -function tooltip(pull: Pull) { - if (pull.participants.length == 1) { - return pull.participating() ? - "Only you participating" : - "1 participant" - } else { - return pull.participating() ? - `${pull.participants.length} participants (including you)` : - `${pull.participants.length} participants`; - } -} diff --git a/frontend/src/pull-card/refresh.tsx b/frontend/src/pull-card/refresh.tsx deleted file mode 100644 index d189afdf..00000000 --- a/frontend/src/pull-card/refresh.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Pull } from "../pull"; -import { refreshPull } from "../backend/pull-socket"; -import { Box, chakra } from "@chakra-ui/react"; -import { useState, memo } from "react"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faRefresh } from "@fortawesome/free-solid-svg-icons"; - -const RefreshContainer = chakra(Box, { - baseStyle: { - lineHeight: 0.9, - visibility: "hidden", - position: "absolute", - cursor: "pointer", - bottom: "3px", - right: "3px", - borderRadius: "4px", - borderWidth: "1px", - borderColor: "var(--refresh)", - padding: "5px", - background: "var(--refresh-background)", - color: "var(--refresh)", - "&.refreshing": { - visibility: "visible", - }, - "&:hover": { - color: "var(--refresh-hover)", - borderColor: "var(--refresh-hover)", - }, - "&:active": { - color: "white", - backgroundColor: "var(--refresh-hover)", - }, - }, -}); - -export const RefreshButton = memo(function RefreshButton({ - pull, -}: { - pull: Pull; -}) { - const [oldReceivedAt, setOldReceivedAt] = useState(new Date()); - const waitingOnRefreshing = oldReceivedAt == pull.received_at; - const handleOnClick = () => { - refreshPull(pull); - setOldReceivedAt(pull.received_at); - }; - return ( - - - - ); -}); diff --git a/frontend/src/pull-card/signatures.tsx b/frontend/src/pull-card/signatures.tsx deleted file mode 100644 index 992cda87..00000000 --- a/frontend/src/pull-card/signatures.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { getUser } from "../page-context"; -import { Pull } from "../pull"; -import { Avatar } from "./avatar"; -import { formatDate } from "../utils"; -import { Signature, SignatureGroup } from "../types"; -import { - chakra, - Box, - HStack, - useStyleConfig, - Popover, - PopoverTrigger, - PopoverContent, - PopoverBody, - PopoverArrow, - PopoverCloseButton, - Portal, -} from "@chakra-ui/react"; -import { memo } from "react"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCheckCircle, faStar } from "@fortawesome/free-solid-svg-icons"; - -interface SignaturesProps { - pull: Pull; - title: string; - signatures: SignatureGroup; - required: number; - // When set (the CR block), show a blue star before the title if a review - // is requested from the current user. - showReviewRequest?: boolean; -} - -const SigSectionTitle = Box; - -function SignaturesFlag({ - pull, - title, - signatures, - required, - showReviewRequest, -}: SignaturesProps) { - const statusVariant = - signatures.current.length >= required - ? pull.isMine() - ? "validMine" - : "valid" - : undefined; - const styles = useStyleConfig("Signatures", { variant: statusVariant }); - const allSignatures = [...signatures.current, ...signatures.old]; - const requiredSignatures = required - ? allSignatures.slice(0, required) - : allSignatures; - const unfulfilledCount = Math.max(0, required - allSignatures.length); - const noneToShow = required == 0 && allSignatures.length == 0; - - return ( - - {showReviewRequest && pull.reviewRequestedFromMe() && ( - - )} - - {title} - - {requiredSignatures.map((sig) => ( - - ))} - {UnfullfilledSigs(unfulfilledCount)} - - ); -} - -export const Signatures = memo(function Signatures(props: SignaturesProps) { - const { pull, signatures } = props; - const noSignatures = - signatures.current.length === 0 && signatures.old.length === 0; - - if (noSignatures) { - return ; - } - - return ( - - - - - - - - - - - - {signatures.current.length > 0 && ( - <> - - Signed Off - - - - )} - {signatures.old.length > 0 && ( - <> - - Previously Signed Off - - - - )} - - - - - ); -}); - -function SignatureBubble({ sig }: { sig: Signature }) { - return ( - - - - ); -} - -function UnfullfilledSigs(count: number) { - const sigs = new Array(count); - for (let i = 0; i < count; i++) { - sigs[i] = ( - - ); - } - return sigs; -} - -function SignatureList({ sigs, pull }: { sigs: Signature[]; pull: Pull }) { - return ( - - {sigs.map((sig) => ( - - ))} - - ); -} - -function SignatureListItem({ sig, pull }: { sig: Signature; pull: Pull }) { - const styles = useStyleConfig("SignatureListItem"); - return ( - - - {formatDate(sig.data.created_at)} by {sig.data.user.login} - - ); -} - -function colorForSignature(sig: Signature): string { - if (sig.data.active) { - return getUser() === sig.data.user.login - ? "var(--signature-valid-mine)" - : "var(--signature-valid)"; - } else { - return getUser() === sig.data.user.login - ? "var(--signature-invalid-mine)" - : "var(--signature-invalid)"; - } -} diff --git a/frontend/src/pull.ts b/frontend/src/pull.ts deleted file mode 100644 index 8014350b..00000000 --- a/frontend/src/pull.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { sortBy } from "lodash-es"; -import { getUser } from "./page-context"; -import { signatureCompare } from "./pulldasher/sort"; -import { - PullData, - Signature, - CommitStatus, - StatusState, - CommentSource, - SignatureGroup, -} from "./types"; - -export class Pull extends PullData { - cr_signatures: SignatureGroup; - qa_signatures: SignatureGroup; - - constructor(data: PullData) { - super(); - Object.assign(this, data); - - this.cr_signatures = computeSignatures(data.status.allCR); - this.qa_signatures = computeSignatures(data.status.allQA); - } - - isOpen(): boolean { - return this.state == "open"; - } - - getUrl(): string { - return "https://github.com/" + this.repo + "/pull/" + this.number; - } - - getRepoName(): string { - return this.repo.replace(/.*\//g, ""); - } - - // Returns a string that is stable and unique to this pull - getKey(): string { - return this.repo + "#" + this.number; - } - - isMine(): boolean { - return this.user.login == getUser(); - } - - isMineViaAffiliation(): boolean { - return ( - this.authoredByMe() || - this.reviewRequestedFromMe() || - this.hasCurrentSig(getUser()) || - this.hasOutdatedSig(getUser()) || - this.hasMyDevBlock() || - this.hasMyDeployBlock() || - this.participating() - ); - } - - isCrDone(): boolean { - return this.cr_signatures.current.length >= this.status.cr_req; - } - - isQaDone(): boolean { - return this.qa_signatures.current.length >= this.status.qa_req; - } - - isCiBlocked(): boolean { - return !this.getDevBlock() && !this.hasPassedCI() && !this.isDraft(); - } - - hasOutdatedSig(user: string) { - return ( - this.cr_signatures.old.some((sig) => sig.data.user.login == user) || - this.qa_signatures.old.some((sig) => sig.data.user.login == user) - ); - } - - hasCurrentSig(user: string) { - return ( - this.cr_signatures.current.some((sig) => sig.data.user.login == user) || - this.qa_signatures.current.some((sig) => sig.data.user.login == user) - ); - } - - participating(): boolean { - return this.participants && this.participants.includes(getUser()); - } - - // The pull author plus its assignees, treated as a combined author list. - authors(): string[] { - return [this.user.login, ...(this.assignees ?? [])].filter( - (login, i, all) => all.indexOf(login) === i - ); - } - - authoredByMe(): boolean { - return this.authors().includes(getUser()); - } - - reviewRequestedFromMe(): boolean { - return this.requested_reviewers?.includes(getUser()) ?? false; - } - - hasMyDevBlock(): boolean { - return this.getDevBlock()?.data.user.login == getUser(); - } - - hasMyDeployBlock(): boolean { - return this.getDeployBlock()?.data.user.login == getUser(); - } - - getDevBlock(): Signature | null { - return this.status.dev_block[0]; - } - - /** - * Returns true if the pull request is in a draft state - */ - isDraft(): boolean { - return this.draft; - } - - /** - * Returns true if the CI requirement has been met - */ - hasPassedCI(): boolean { - const statuses = this.buildStatuses(); - return this.getRequiredBuildStatuses().every((context) => { - const status = statuses.find((status) => status.data.context == context); - return status && isSuccessfulStatus(status); - }); - } - - /** - * Returns true if there are required CI statues OR if there are *any* CI - * statuses - */ - isCiRequired(): boolean { - return ( - this.getRequiredBuildStatuses().length > 0 || - this.buildStatuses().length > 0 - ); - } - - isReady(): boolean { - return ( - this.hasMetDeployRequirements() && - !this.isDraft() && - !this.getDevBlock() && - !this.getDeployBlock() && - !this.hasMergeConflicts() - ); - } - - isDeployBlocked(): boolean { - return ( - this.hasMetDeployRequirements() && - !this.getDevBlock() && - (!!this.getDeployBlock() || this.hasMergeConflicts() || this.isDependent()) - ); - } - - isDependent(): boolean { - return !(this.base.ref == "main" || this.base.ref == "master"); - } - - hasMetDeployRequirements(): boolean { - return this.isQaDone() && this.isCrDone() && this.hasPassedCI(); - } - - getDeployBlock(): Signature | null { - return this.status.deploy_block[0]; - } - - getLabel(title: string) { - return this.labels.find((label) => label.title == title); - } - - buildStatuses(): CommitStatus[] { - return this.status.commit_statuses; - } - - hasBuildStatus(context: string): boolean { - return this.status.commit_statuses.some( - (status) => status.data.context === context - ); - } - - hasMergeConflicts(): boolean { - return this.mergeable == false; - } - - buildStatusesWithRequired(): CommitStatus[] { - const statuses = this.buildStatuses(); - (this.repoSpec?.requiredStatuses || []).forEach((requiredContext) => { - if (!this.hasBuildStatus(requiredContext)) { - statuses.push({ - data: { - sha: "unknown", // unused and doesn't matter - target_url: null, - description: "Missing (not started)", - state: StatusState.pending, - context: requiredContext, - started_at: null, - completed_at: null, - }, - }); - } - }); - return sortBy(statuses, [(status) => status.data.context.toLowerCase()]); - } - - getRequiredBuildStatuses(): string[] { - return ( - this.repoSpec?.requiredStatuses ?? - // If there are no required statuses, then all existing statuses - // are required to be passing unless they are ignored. - this.getBuildStatusesWithoutIgnored() - ); - } - - getBuildStatusesWithoutIgnored(): string[] { - return this.buildStatuses() - .map((status) => status.data.context) - .filter((context) => - !this.repoSpec?.ignoredStatuses?.includes(context) - ); - } - - url() { - return "https://github.com/" + this.repo + "/pull/" + this.number; - } - - linkToSignature(sig: Signature): string { - const isReview = sig.data.source_type === CommentSource.review; - const linkIdPrefix = isReview ? "pullrequestreview" : "issuecomment"; - return this.url() + `#${linkIdPrefix}-${sig.data.comment_id}`; - } - - getUrlOfBaseBranch() { - return "https://github.com/" + this.repo + "/compare/" + encodeURIComponent(this.base.ref); - } -} - -function computeSignatures(signatures: Signature[]): SignatureGroup { - const groups: SignatureGroup = { - current: [], - old: [], - }; - const users: Record = {}; - - signatures.sort(signatureCompare).forEach(function (signature) { - if (users[signature.data.user.login]) { - return; - } - - if (signature.data.active) { - groups.current.push(signature); - } else { - groups.old.push(signature); - } - - users[signature.data.user.login] = true; - }); - - return groups; -} - -function isSuccessfulStatus(status: CommitStatus) { - return status.data.state === "success"; -} diff --git a/frontend/src/pulldasher/filtered-pulls-state.ts b/frontend/src/pulldasher/filtered-pulls-state.ts deleted file mode 100644 index 5b745f7b..00000000 --- a/frontend/src/pulldasher/filtered-pulls-state.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useState, useCallback } from "react"; -import { Pull } from "../pull"; - -export type FilterFunction = (pull: Pull) => boolean; -export type FilterFunctionSetter = ( - filterName: string, - filter: FilterFunction | null -) => void; -// Map of human name to filter functions -type Filters = Record; -type ReturnType = [Pull[], FilterFunctionSetter]; - -/** - * Wrapper around an array of pulls that allows multiple filters to be - * specified. - * setFilter(name, func) will *remove* the filter if func is null - */ -export function useFilteredPullsState(pulls: Pull[]): ReturnType { - const [filters, setFilter] = useState({}); - const replaceNamedFilter = useCallback( - (filterName: string, filter: FilterFunction | null) => { - // Use the functional form of `setState()` so we can base our new - // value on the previous one. - setFilter((currentFilters) => { - if (filter) { - currentFilters[filterName] = filter; - } else { - delete currentFilters[filterName]; - } - return { ...currentFilters }; - }); - }, - [] - ); - - return [filterPulls(pulls, filters), replaceNamedFilter]; -} - -function filterPulls(pulls: Pull[], filters: Filters): Pull[] { - return Object.values(filters).reduce( - (pulls, filter) => pulls.filter(filter), - pulls - ); -} diff --git a/frontend/src/pulldasher/index.tsx b/frontend/src/pulldasher/index.tsx deleted file mode 100644 index 58b2df62..00000000 --- a/frontend/src/pulldasher/index.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useAllPulls, useAllOpenPulls } from "./pulls-context"; -import { Navbar } from "../navbar"; -import { Column } from "../column"; -import { QACompare, DeployCompare } from "./sort"; -import { LeaderList, getLeaders } from "../leader-list"; -import { - useMyPullNotification, - useMyReviewNotification, -} from "./notifications"; -import { Box, SimpleGrid, VStack } from "@chakra-ui/react"; -import { useBoolUrlState } from "../use-url-state"; -import { ClosedPulls } from "../closed-pulls"; - -export function Page() { - const [showClosedPulls, toggleShowClosedPulls] = useBoolUrlState( - "closed", - false - ); - return ( - <> - - - {showClosedPulls && } - - ); -} - -function Pulldasher() { - const allPulls = useAllPulls(); - const pulls = useAllOpenPulls(); - const pullsCIBlocked = pulls.filter((pull) => pull.isCiBlocked()); - const pullsDeployBlocked = pulls.filter((pull) => pull.isDeployBlocked()); - const pullsReady = pulls.filter( - (pull) => pull.isReady() && !pull.isDependent() - ); - const pullsDevBlocked = pulls.filter( - (pull) => pull.getDevBlock() || pull.isDraft() - ); - const pullsNeedingCR = pulls.filter( - (pull) => !pull.isCrDone() && !pull.getDevBlock() && !pull.isDraft() - ); - const pullsNeedingQA = pulls.filter( - (pull) => - !pull.isQaDone() && - !pull.getDevBlock() && - !pull.isDraft() && - !pull.hasMergeConflicts() && - pull.hasPassedCI() - ); - const leadersCR = getLeaders(allPulls, (pull) => pull.status.allCR); - useMyPullNotification(pullsReady, "merge"); - useMyReviewNotification([...pullsNeedingCR, ...pullsNeedingQA], "re-review"); - return ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/frontend/src/pulldasher/notifications.tsx b/frontend/src/pulldasher/notifications.tsx deleted file mode 100644 index 16278ccb..00000000 --- a/frontend/src/pulldasher/notifications.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useNotification } from "../notifications"; -import { getUser } from "../page-context"; -import { Pull } from "../pull"; - -export function usePullNotification( - pulls: Pull[], - action: string, - filter: (pull: Pull) => boolean -) { - useNotification(pulls, { - key: (pull: Pull) => pull.number, - filter, - message(pulls) { - const titles = pulls.map((p) => p.title).join(", "); - const pull = pulls.length === 1 ? "Pull" : "Pulls"; - return `${pull} ready for ${action}: ${titles}`; - }, - }); -} - -export function useMyPullNotification(pulls: Pull[], action: string) { - usePullNotification(pulls, action, (pull: Pull) => pull.isMine()); -} -export function useMyReviewNotification(pulls: Pull[], action: string) { - usePullNotification(pulls, action, (pull: Pull) => - pull.hasOutdatedSig(getUser()) - ); -} diff --git a/frontend/src/pulldasher/pulls-context.tsx b/frontend/src/pulldasher/pulls-context.tsx deleted file mode 100644 index 6d49f2a5..00000000 --- a/frontend/src/pulldasher/pulls-context.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { createContext, useContext } from "react"; -import { - useFilteredPullsState, - FilterFunction, - FilterFunctionSetter, -} from "./filtered-pulls-state"; -import { usePullsState } from "./pulls-state"; -import { Pull } from "../pull"; -import { RepoSpec } from "../types"; -import { defaultCompare } from "./sort"; - -interface PullContextProps { - // Unsorted array of all pulls (open + recently closed) - allPulls: Pull[]; - // Sorted array of all open pulls (open) - allOpenPulls: Pull[]; - // Array of pulls from allPulls that pass the filter function - filteredPulls: Pull[]; - // Set of open pulls passing the filter function - filteredOpenPulls: Set; - // Changes the filter function - setFilter: FilterFunctionSetter; - // RepoSpecs (.repos) from the config - repoSpecs: RepoSpec[]; -} - -const defaultProps = { - allPulls: [], - allOpenPulls: [], - filteredOpenPulls: new Set(), - filteredPulls: [], - // Default implementation is a no-op, just so there's - // something there until the provider is used - setFilter: (name: string, filter: FilterFunction) => filter, - repoSpecs: [], -}; -const PullsContext = createContext(defaultProps); - -export function useAllOpenPulls(): Pull[] { - return useContext(PullsContext).allOpenPulls; -} - -export function useAllPulls(): Pull[] { - return useContext(PullsContext).allPulls; -} - -export function useFilteredOpenPulls(): Set { - return useContext(PullsContext).filteredOpenPulls; -} - -export function useFilteredPulls(): Pull[] { - return useContext(PullsContext).filteredPulls; -} - -export function useSetFilter(): FilterFunctionSetter { - return useContext(PullsContext).setFilter; -} - -export function useRepoSpecs(): RepoSpec[] { - return useContext(PullsContext).repoSpecs; -} - -export const PullsProvider = function ({ - children, -}: { - children: React.ReactNode; -}) { - const {pullState: unfilteredPulls, repoSpecs} = usePullsState(); - const [filteredPulls, setFilter] = useFilteredPullsState(unfilteredPulls); - const openPulls = unfilteredPulls.filter(isOpen); - const contextValue = { - filteredOpenPulls: new Set(filteredPulls.filter(isOpen)), - allOpenPulls: openPulls.sort(defaultCompare), - filteredPulls: filteredPulls, - allPulls: unfilteredPulls, - setFilter, - repoSpecs, - }; - return ( - - {children} - - ); -}; - -const isOpen = (pull: Pull) => pull.isOpen(); diff --git a/frontend/src/pulldasher/pulls-state.tsx b/frontend/src/pulldasher/pulls-state.tsx deleted file mode 100644 index f2f7496a..00000000 --- a/frontend/src/pulldasher/pulls-state.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useState, useEffect } from "react"; -import { throttle } from "lodash-es"; -import { createPullSocket } from "../backend/pull-socket"; -import { PullData, RepoSpec } from "../types"; -import { Pull } from "../pull"; - -function onPullsChanged(pullsChanged: (pulls: Pull[], repoSpecs: RepoSpec[]) => void) { - const pulls: Record = {}; - const pullRefresh = () => pullsChanged(Object.values(pulls), repoSpecs); - const throttledPullRefresh: () => void = throttle(pullRefresh, 500); - let repoSpecs: RepoSpec[] = []; - - createPullSocket((pullDatas: PullData[], newRepoSpecs: RepoSpec[]) => { - pullDatas.forEach((pullData: PullData) => { - pullData.repoSpec = - newRepoSpecs.find((repo) => repo.name == pullData.repo) || null; - pullData.received_at = new Date(); - const pull: Pull = new Pull(pullData); - pulls[pull.getKey()] = pull; - }); - - repoSpecs = newRepoSpecs || []; - throttledPullRefresh(); - }); -} - -/** - * Note: This is only meant to be used in one component - */ -let socketInitialized = false; -export function usePullsState() { - const [pullState, setPullsState] = useState([]); - const [repoSpecs, setRepoSpecs] = useState([]); - useEffect(() => { - if (socketInitialized) { - throw new Error( - "usePullsState() connects to socket-io and is only meant to be used in the PullsProvider component, see useFilteredOpenPulls() instead." - ); - } - socketInitialized = true; - onPullsChanged((pulls, repoSpecs) => { - setPullsState(pulls); - setRepoSpecs(repoSpecs); - }); - }, []); - return {pullState, repoSpecs}; -} diff --git a/frontend/src/pulldasher/sort.ts b/frontend/src/pulldasher/sort.ts deleted file mode 100644 index 388b01e1..00000000 --- a/frontend/src/pulldasher/sort.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Pull } from "../pull"; -import { getUser } from "../page-context"; -import { Signature } from "../types"; - -export function defaultCompare(a: Pull, b: Pull): number { - return ( - // My pulls above pulls that aren't mine - compareBool(a.isMine(), b.isMine()) || - // Pulls I have to CR/QA above those I don't - compareBool(a.hasOutdatedSig(getUser()), b.hasOutdatedSig(getUser())) || - // Pulls I haven't touched vs those I have already CRed - compareBool(!a.hasCurrentSig(getUser()), !b.hasCurrentSig(getUser())) || - // younger pulls above older pulls so new things get review sooner - b.created_at.localeCompare(a.created_at) - ); -} - -export function QACompare(a: Pull, b: Pull): number { - return ( - // Pulls I'm QAing above those I'm not - compareBool(isQAingByMe(a), isQAingByMe(b)) || - // Pulls with no external_block above those with external_block - compareBool(!a.getLabel("external_block"), !b.getLabel("external_block")) || - // Pulls with no merge conflicts above those with merge conflicts - compareBool(!a.hasMergeConflicts(), !b.hasMergeConflicts()) || - // Pulls with no QAing label above those with QAing - compareBool(!a.getLabel("QAing"), !b.getLabel("QAing")) || - // Pulls with CR completed above those that need more - compareBool(a.isCrDone(), b.isCrDone()) || - // younger pulls before older pulls - b.created_at.localeCompare(a.created_at) - ); -} - -export function DeployCompare(a: Pull, b: Pull): number { - return ( - // Pulls with no merge conflicts above those with merge conflicts - compareBool(!a.hasMergeConflicts(), !b.hasMergeConflicts()) - ); -} - -export function signatureCompare(a: Signature, b: Signature) { - return ( - // Active before inactive - b.data.active - a.data.active || - // My sigs before others - compareBool( - a.data.user.login == getUser(), - b.data.user.login == getUser() - ) || - // Older before newer - a.data.created_at.localeCompare(b.data.created_at) - ); -} - -export function closedAtCompare(a: Pull, b: Pull) { - const astr = String(a.closed_at); - const bstr = String(b.closed_at); - return astr < bstr ? 1 : astr > bstr ? -1 : 0; -} - -function isQAingByMe(pull: Pull): boolean { - const label = pull.getLabel("QAing"); - return label?.user == getUser(); -} - -function compareBool(a: boolean, b: boolean): number { - return +b - +a; -} diff --git a/frontend/src/theme.tsx b/frontend/src/theme.tsx deleted file mode 100644 index ccd24e53..00000000 --- a/frontend/src/theme.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { extendTheme } from "@chakra-ui/react"; -import "./theme/base.less"; -import "./theme/day_theme.less"; -import "./theme/night_theme.less"; - -export const theme = extendTheme({ - initialColorMode: "light", - useSystemColorMode: false, - components: { - Link: { - baseStyle: { - "&:hover": { - textDecoration: "none", - }, - }, - }, - Column: { - baseStyle: { - border: "1px solid var(--panel-default-border)", - borderRadius: "7px", - "& .column_header": { - color: "var(--panel-default-text)", - cursor: "pointer", - background: "var(--panel-default-background)", - fontSize: "1rem", - }, - "& .pull_count": { - minWidth: "50px", - textAlign: "center", - color: "var(--panel-default-count-text)", - borderLeft: "solid 1px var(--panel-default-count-border)", - background: "var(--panel-default-count-background)", - }, - }, - variants: { - ciBlocked: { - borderColor: "var(--panel-ci-blocked-border)", - "& .column_header": { - background: "var(--panel-ci-blocked-background)", - color: "var(--panel-ci-blocked-text)", - }, - "& .pull_count": { - color: "var(--panel-ci-blocked-count-text)", - borderLeft: "solid 1px var(--panel-ci-blocked-count-border)", - background: "var(--panel-ci-blocked-count-background)", - }, - }, - deployBlocked: { - borderColor: "var(--panel-deploy-blocked-border)", - "& .column_header": { - background: "var(--panel-deploy-blocked-background)", - color: "var(--panel-deploy-blocked-text)", - }, - "& .pull_count": { - color: "var(--panel-deploy-blocked-count-text)", - borderLeft: "solid 1px var(--panel-deploy-blocked-count-border)", - background: "var(--panel-deploy-blocked-count-background)", - }, - }, - ready: { - borderColor: "var(--panel-ready-border)", - "& .column_header": { - background: "var(--panel-ready-background)", - color: "var(--panel-ready-text)", - }, - "& .pull_count": { - color: "var(--panel-ready-count-text)", - borderLeft: "solid 1px var(--panel-ready-count-border)", - background: "var(--panel-ready-count-background)", - }, - }, - closed: { - borderRadius: "7px 0 0 7px", - backgroundColor: "var(--background-color)", - "& .column_header": { - borderRadius: "7px 0 0 0", - }, - }, - }, - }, - StatusGroup: { - baseStyle: { - display: "block", - pos: "relative", - w: "100%", - opacity: 1, - transition: "opacity 0.3s ease-in-out", - borderRadius: "5px", - }, - variants: { - pending: { - bg: "var(--build-state-pending)", - }, - success: { - bg: "var(--build-state-success)", - opacity: 0, - }, - error: { - bg: "var(--build-state-error)", - }, - failure: { - bg: "var(--build-state-failure)", - }, - }, - }, - StatusLink: { - baseStyle: { - borderBottom: "1px solid var(--build-status-link-divider)", - "&:last-child": { - borderBottom: "none", - }, - padding: "5px", - "> span": { - color: "var(--build-status-text)", - marginLeft: "10px", - }, - "&:hover": { - background: "var(--build-status-hover)", - }, - }, - variants: { - pending: { - color: "var(--build-state-pending)", - }, - success: { - color: "var(--build-state-success)", - }, - error: { - color: "var(--build-state-error)", - }, - failure: { - color: "var(--build-state-failure)", - }, - }, - }, - Signatures: { - baseStyle: { - fontSize: 14, - fontWeight: 700, - borderRadius: "3px", - backgroundColor: "var(--status-background)", - borderWidth: "1px", - borderColor: "var(--status-border)", - color: "var(--status-text)", - }, - variants: { - valid: { - backgroundColor: "var(--status-valid-background)", - borderColor: "var(--status-valid-border)", - color: "var(--status-valid-text)", - }, - validMine: { - backgroundColor: "var(--status-valid-mine-background)", - borderColor: "var(--status-valid-mine-border)", - color: "var(--status-valid-mine-text)", - }, - }, - }, - SignatureListItem: { - baseStyle: { - display: "block", - color: "var(--build-status-text)", - borderBottom: "1px solid var(--build-status-link-divider)", - "&:last-child": { - borderBottom: "none", - }, - padding: "5px", - "&:hover": { - background: "var(--build-status-hover)", - }, - }, - }, - PullFlag: { - baseStyle: { - py: "5px", - px: "6px", - lineHeight: "1em", - borderRadius: "3px", - fontSize: "18px", - border: "solid 1px", - }, - variants: { - devBlock: { - color: "var(--tag-dev-blocked)", - backgroundColor: "var(--tag-dev-blocked-background)", - borderColor: "var(--tag-dev-blocked-border)", - }, - draftBlock: { - color: "var(--tag-draft-blocked)", - backgroundColor: "var(--tag-draft-blocked-background)", - borderColor: "var(--tag-draft-blocked-border)", - }, - deployBlock: { - color: "var(--tag-deploy-blocked)", - backgroundColor: "var(--tag-deploy-blocked-background)", - borderColor: "var(--tag-deploy-blocked-border)", - }, - QAing: { - color: "var(--tag-qaing)", - backgroundColor: "var(--tag-qaing-background)", - borderColor: "var(--tag-qaing-border)", - }, - externalBlock: { - color: "var(--tag-externally-blocked)", - backgroundColor: "var(--tag-externally-blocked-background)", - borderColor: "var(--tag-externally-blocked-border)", - }, - cryogenicStorage: { - color: "var(--tag-cryo)", - backgroundColor: "var(--tag-cryo-background)", - borderColor: "var(--tag-cryo-border)", - }, - mergeConflict: { - color: "var(--tag-merge-conflict)", - backgroundColor: "var(--tag-merge-conflict-background)", - borderColor: "var(--tag-merge-conflict-border)", - }, - dependentPR: { - color: "var(--tag-dependent-pr)", - backgroundColor: "var(--tag-dependent-pr-background)", - borderColor: "var(--tag-dependent-pr-border)", - }, - }, - }, - }, -}); diff --git a/frontend/src/theme/base.less b/frontend/src/theme/base.less deleted file mode 100644 index 73246c82..00000000 --- a/frontend/src/theme/base.less +++ /dev/null @@ -1,34 +0,0 @@ -body.themed { - background-color: var(--background-color); - - --body-max-width: 1200px; - - // Padding around columns - --body-gutter: 24px; - - font: 400 14px Lato; - line-height: 1.5; - - .pull-flag:hover { - box-shadow: 0 0 0 1px; - } -} - -@keyframes highlightPull { - from { - background-color: var(--pull-updated-background); - } -} - -body.themed, -html { - min-height: 100vh; -} - -*:focus { - box-shadow: none !important; -} - -*[data-focus] { - box-shadow: none !important; -} diff --git a/frontend/src/theme/day_theme.less b/frontend/src/theme/day_theme.less deleted file mode 100644 index cd9887be..00000000 --- a/frontend/src/theme/day_theme.less +++ /dev/null @@ -1,184 +0,0 @@ -body.chakra-ui-light, -body[data-theme="day_theme"] { - @blue: #3683c5; - @green: #6cb644; - @yellow: #e7b049; - @red: #db524b; - - @closed: #cf222e; - @merged: #8250df; - - @white: #ffffff; - @light-grey: #f5f5f5; - @grey: #e7e7e7; - @dark-grey: #555555; - @black: #000000; - - --background-color: @white; - - // Header - --header-background: @light-grey; - --header-border: @grey; - - --theme-selector-text: @dark-grey; - --theme-selector-active: @black; - --theme-selector-inactive: @dark-grey; - - --toggle-border: darken(@grey, 10%); - --toggle-background: @light-grey; - --toggle-background-hover: @grey; - - --brand-color: @dark-grey; - --brand-highlight: @blue; - - --global-indicator-text: @dark-grey; - --global-indicator-text-link: @blue; - --global-indicator-count-text: @blue; - --global-indicator-count-border: @white; - --global-indicator-count-background: @white; - - // Leaderboard - --leaderboard-background: @light-grey; - --leaderboard-border: @grey; - --leaderboard-title-text: @dark-grey; - - --leaderboard-posting-background: @blue; - --leaderboard-posting-border: @blue; - --leaderboard-posting-count: @white; - - --leaderboard-leader-background: @green; - --leaderboard-leader-border: @green; - --leaderboard-leader-count: @white; - - // Pulls - --panel-ci-blocked-text: @black; - --panel-ci-blocked-background: lighten(@yellow, 15%); - --panel-ci-blocked-border: @yellow; - --panel-ci-blocked-count-text: @black; - --panel-ci-blocked-count-background: lighten(@yellow, 5%); - --panel-ci-blocked-count-border: darken(@yellow, 10%); - - --panel-deploy-blocked-text: @black; - --panel-deploy-blocked-background: lighten(@red, 15%); - --panel-deploy-blocked-border: @red; - --panel-deploy-blocked-count-text: @black; - --panel-deploy-blocked-count-background: lighten(@red, 5%); - --panel-deploy-blocked-count-border: darken(@red, 10%); - - --panel-ready-text: @black; - --panel-ready-background: lighten(@green, 15%); - --panel-ready-border: @green; - --panel-ready-count-text: @black; - --panel-ready-count-background: lighten(@green, 5%); - --panel-ready-count-border: darken(@green, 10%); - - --panel-default-text: @black; - --panel-default-background: @light-grey; - --panel-default-border: darken(@light-grey, 15%); - --panel-default-count-text: @black; - --panel-default-count-background: darken(@light-grey, 15%); - --panel-default-count-border: darken(@light-grey, 15%); - - --pull-title: @dark-grey; - --pull-number: @black; - --pull-background: @white; - --pull-background-hover: @light-grey; - --pull-separator: darken(@light-grey, 10%); - --pull-updated-background: #fff3a6; - --pull-merged: @merged; - --pull-closed: @closed; - - // Build statuses - --build-state-pending: @grey; - --build-state-failure: @yellow; - --build-state-error: @red; - --build-state-success: @green; - --build-status-link-divider: rgba(128, 128, 128, 0.2); - --build-status-hover: var(--build-status-link-divider); - --build-status-text: @dark-grey; - - --tag-default-border: darken(@light-grey, 10%); - --tag-default-background: @white; - - --tag-current-milestone: @blue; - --tag-current-milestone-border: var(--tag-default-border); - --tag-current-milestone-background: var(--tag-default-background); - - --tag-past-milestone: @red; - --tag-past-milestone-border: var(--tag-default-border); - --tag-past-milestone-background: var(--tag-default-background); - - --tag-dev-blocked: @yellow; - --tag-dev-blocked-border: var(--tag-default-border); - --tag-dev-blocked-background: var(--tag-default-background); - - --tag-draft-blocked: @blue; - --tag-draft-blocked-border: var(--tag-default-border); - --tag-draft-blocked-background: var(--tag-default-background); - - --tag-deploy-blocked: @red; - --tag-deploy-blocked-border: var(--tag-default-border); - --tag-deploy-blocked-background: var(--tag-default-background); - - --tag-qaing: @green; - --tag-qaing-mine: @blue; - --tag-qaing-border: var(--tag-default-border); - --tag-qaing-background: var(--tag-default-background); - - --tag-externally-blocked: @red; - --tag-externally-blocked-border: var(--tag-default-border); - --tag-externally-blocked-background: var(--tag-default-background); - - --tag-cryo: var(--tag-default-background); - --tag-cryo-border: var(--tag-default-border); - --tag-cryo-background: @blue; - - --tag-merge-conflict: @yellow; - --tag-merge-conflict-border: var(--tag-default-border); - --tag-merge-conflict-background: var(--tag-default-background); - - --tag-dependent-pr: @red; - --tag-dependent-pr-border: var(--tag-default-border); - --tag-dependent-pr-background: var(--tag-default-background); - - --user-icon: @blue; - - // Pull age - --age-1: @green; - --age-2: @yellow; - --age-3: @red; - - // CR and QA boxes - --status-background: @white; - --status-border: darken(@light-grey, 10%); - --status-text: @dark-grey; - - --status-valid-background: lighten(@green, 35%); - --status-valid-border: @green; - --status-valid-text: @green; - - --status-valid-mine-background: lighten(@blue, 35%); - --status-valid-mine-border: @blue; - --status-valid-mine-text: @blue; - - // Signatures (CR / QA) - --signature-default: darken(@grey, 10%); - --signature-valid: @green; - --signature-valid-mine: @blue; - --signature-invalid: @yellow; - --signature-invalid-mine: @red; - - --copy-branch: darken(@grey, 25%); - --copy-branch-hover: darken(@blue, 10%); - - --refresh: darken(@grey, 25%); - --refresh-background: @white; - --refresh-hover: @blue; - - --participants-without-me: @grey; - --participants-including-me: @blue; - - // Lines Changed - --additions: darken(@green, 10%); - --deletions: @red; -} diff --git a/frontend/src/theme/night_theme.less b/frontend/src/theme/night_theme.less deleted file mode 100644 index 4f29943c..00000000 --- a/frontend/src/theme/night_theme.less +++ /dev/null @@ -1,193 +0,0 @@ -body.chakra-ui-dark, -body[data-theme="night_theme"] { - @blue: #3683c5; - @green: #67ba76; - @yellow: #d59a6a; - @red: #e66873; - - @dark-green: #334c3e; - @dark-yellow: #4b4139; - @dark-red: #50343c; - - @white: #f5f5f5; - @light-grey: #abb2c0; - @grey: #777777; - @dark-grey: #2d3239; - @steel: #20242b; - @black: #171a1f; - - --background-color: @steel; - - // Header - --header-background: @black; - --header-border: @black; - - --theme-selector-text: @light-grey; - --theme-selector-active: @white; - --theme-selector-inactive: @grey; - - --toggle-border: @light-grey; - --toggle-background: @steel; - --toggle-background-hover: @dark-grey; - - --brand-color: @light-grey; - --brand-highlight: @blue; - - --global-indicator-text: @light-grey; - --global-indicator-text-link: @blue; - --global-indicator-count-text: @white; - --global-indicator-count-border: @steel; - --global-indicator-count-background: @steel; - - // Leaderboard - --leaderboard-background: @black; - --leaderboard-border: @black; - --leaderboard-title-text: @light-grey; - - --leaderboard-posting-background: darken(@blue, 30%); - --leaderboard-posting-border: darken(@blue, 30%); - --leaderboard-posting-count: @blue; - - --leaderboard-leader-background: @dark-green; - --leaderboard-leader-border: @dark-green; - --leaderboard-leader-count: @green; - - // Pulls - --panel-ci-blocked-text: @yellow; - --panel-ci-blocked-background: @dark-yellow; - --panel-ci-blocked-border: @dark-yellow; - --panel-ci-blocked-count-text: @white; - --panel-ci-blocked-count-background: #906e51; - --panel-ci-blocked-count-border: @dark-yellow; - - --panel-deploy-blocked-text: @red; - --panel-deploy-blocked-background: @dark-red; - --panel-deploy-blocked-border: @dark-red; - --panel-deploy-blocked-count-text: @white; - --panel-deploy-blocked-count-background: #9b4d57; - --panel-deploy-blocked-count-border: @dark-red; - - --panel-ready-text: @green; - --panel-ready-background: @dark-green; - --panel-ready-border: @dark-green; - --panel-ready-count-text: @white; - --panel-ready-count-background: #569262; - --panel-ready-count-border: @dark-green; - - --panel-default-text: #a7afbd; - --panel-default-background: @dark-grey; - --panel-default-border: @dark-grey; - --panel-default-count-text: @white; - --panel-default-count-background: #3a4148; - --panel-default-count-border: @dark-grey; - - --pull-title: @light-grey; - --pull-number: @white; - --pull-background: @steel; - --pull-background-hover: @black; - - --pull-separator: lighten(@steel, 10%); - - // Build statuses - --build-state-pending: @grey; - --build-state-failure: @yellow; - --build-state-error: @red; - --build-state-success: @green; - --build-status-link-divider: rgba(128, 128, 128, 0.2); - --build-status-hover: var(--build-status-link-divider); - --build-status-text: @light-grey; - - --tag-default-border: @dark-grey; - --tag-default-background: @dark-grey; - - --tag-current-milestone: @blue; - --tag-current-milestone-border: var(--tag-default-border); - --tag-current-milestone-background: var(--tag-default-background); - - --tag-past-milestone: @red; - --tag-past-milestone-border: var(--tag-default-border); - --tag-past-milestone-background: var(--tag-default-background); - - --tag-dev-blocked: @yellow; - --tag-dev-blocked-border: var(--tag-default-border); - --tag-dev-blocked-background: var(--tag-default-background); - - --tag-draft-blocked: @blue; - --tag-draft-blocked-border: var(--tag-default-border); - --tag-draft-blocked-background: var(--tag-default-background); - - --tag-deploy-blocked: @red; - --tag-deploy-blocked-border: var(--tag-default-border); - --tag-deploy-blocked-background: var(--tag-default-background); - - --tag-qaing: @green; - --tag-qaing-mine: @blue; - --tag-qaing-border: var(--tag-default-border); - --tag-qaing-background: var(--tag-default-background); - - --tag-externally-blocked: @red; - --tag-externally-blocked-border: var(--tag-default-border); - --tag-externally-blocked-background: var(--tag-default-background); - - --tag-merge-conflict: @yellow; - --tag-merge-conflict-border: var(--tag-default-border); - --tag-merge-conflict-background: var(--tag-default-background); - - --tag-dependent-pr: @red; - --tag-dependent-pr-border: var(--tag-default-border); - --tag-dependent-pr-background: var(--tag-default-background); - - --user-icon: @blue; - - // Pull age - --age-1: @green; - --age-2: @yellow; - --age-3: @red; - - // CR and QA boxes - --status-background: @dark-grey; - --status-border: @dark-grey; - --status-text: @light-grey; - - --status-valid-background: @dark-green; - --status-valid-border: @dark-green; - --status-valid-text: @green; - - --status-valid-mine-background: darken(@blue, 30%); - --status-valid-mine-border: darken(@blue, 30%); - --status-valid-mine-text: @blue; - - // Signatures (CR / QA) - --signature-default: @steel; - --signature-valid: @green; - --signature-valid-mine: @blue; - --signature-invalid: @yellow; - --signature-invalid-mine: @red; - - --refresh: darken(@grey, 15%); - --refresh-background: @steel; - --refresh-hover: @blue; - - --participants-without-me: darken(@grey, 15%); - --participants-including-me: @blue; - - --copy-branch: darken(@grey, 15%); - --copy-branch-hover: @blue; - - // Lines Changed - --additions: @green; - --deletions: lighten(@red, 5%); - - i.fa.fa-sun-o:hover { - color: @yellow !important; - } - - .panel-title { - font-weight: 700; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); - } - - .pull-title .pull-number { - font-weight: 400; - } -} diff --git a/frontend/src/types.ts b/frontend/src/types.ts deleted file mode 100644 index bad323fe..00000000 --- a/frontend/src/types.ts +++ /dev/null @@ -1,130 +0,0 @@ -export enum PullState { - open = "open", - closed = "closed", -} - -export type DateString = string; - -export enum StatusState { - error = "error", - pending = "pending", - success = "success", - failure = "failure", -} - -export enum CommentSource { - comment = "comment", - review = "review", -} - -export enum SignatureType { - CR = "CR", - QA = "QA", - dev_block = "dev_block", - deploy_block = "deploy_block", -} - -export interface SignatureUser { - id: number; - login: string; -} - -export interface RepoSpec { - requiredStatuses?: string[]; - ignoredStatuses?: string[]; - hideByDefault?: boolean; - name: string; -} - -export interface Label { - title: string; - number: number; - repo: string; - user: string; - created_at: DateString; -} - -export interface Signature { - data: { - repo: string; - number: number; - user: SignatureUser; - type: SignatureType; - created_at: DateString; - active: number; - comment_id: number; - source_type: CommentSource; - }; -} - -export interface SignatureGroup { - // Contains all signatures that are active - current: Signature[]; - // Contains all signatures that are inactive from users without signatures in current - old: Signature[]; -} - -export interface CommitStatus { - data: { - sha: string; - target_url: string | null; - description: string; - state: StatusState; - context: string; - started_at: number | null; - completed_at: number | null; - }; -} - -export class PullData { - repo: string; - repoSpec: RepoSpec | null; - number: number; - state: PullState; - title: string; - body: string; - draft: boolean; - received_at: Date | null; - created_at: DateString; - updated_at: DateString; - closed_at: DateString | null; - mergeable: boolean | null; - merged_at: DateString | null; - difficulty: number | null; - additions: number | null; - deletions: number | null; - milestone: { - title: string | null; - due_on: string | null; - }; - head: { - ref: string; - sha: string; - repo: { - owner: { - login: string; - }; - }; - }; - base: { - ref: string; - }; - user: { - login: string; - }; - cr_req: number; - qa_req: number; - status: { - qa_req: number; - cr_req: number; - allQA: Signature[]; - allCR: Signature[]; - dev_block: Signature[]; - deploy_block: Signature[]; - commit_statuses: CommitStatus[]; - }; - labels: Label[]; - participants: string[]; - assignees: string[]; - requested_reviewers: string[]; -} diff --git a/frontend/src/use-url-state.ts b/frontend/src/use-url-state.ts deleted file mode 100644 index 50fb556d..00000000 --- a/frontend/src/use-url-state.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { useState, useCallback, useEffect, useMemo } from "react"; - -type state = string | null; -type stateSetter = (state: state) => void; -type stringSetter = (state: string) => void; - -type useUrlStateReturn = [string, stateSetter]; - -type HistoryState = Record; - -/** - * like useState(), but the state is stored in the url as a query param. - * Loads it from a query param on page refresh and updates the url as the state - * changes. - * paramName is expected not to change over the life of the component. - */ -export function useUrlState( - paramName: string, - paramDefault: string -): useUrlStateReturn { - const [state, setState] = useState(() => - parseStateFromUrl(paramName, paramDefault) - ); - - // Watch for url state changes (back button) only once per component. - useEffect(() => watchForPopstate(paramName, setState, paramDefault), []); - - // Wrap setState so it pushes history and transforms the url - const setUrlState = useCallback((newState: state) => { - // Turn null into the default so state is never null - setState(newState || paramDefault); - // Turn default into null so that default values disappear from the url - pushStateToUrl(paramName, newState === paramDefault ? null : newState); - }, []); - - return [state, setUrlState]; -} - -function parseStateFromUrl(paramName: string, paramDefault: string): string { - const url = new URL(window.location.href); - return url.searchParams.get(paramName) ?? paramDefault; -} - -function watchForPopstate( - paramName: string, - setState: stringSetter, - paramDefault: string -) { - const handler = (event: PopStateEvent) => { - const stateFromHistory = event.state ? event.state[paramName] : undefined; - setState(stateFromHistory || parseStateFromUrl(paramName, paramDefault)); - }; - window.addEventListener("popstate", handler); - return () => window.removeEventListener("popstate", handler); -} - -/** - * Update the current url to match the new state and push the new historyState - * on the stack - */ -function pushStateToUrl(paramName: string, newState: state): void { - const url = new URL(window.location.href); - if (newState) { - url.searchParams.set(paramName, newState); - } else { - url.searchParams.delete(paramName); - } - history.pushState(urlToStateObject(url), "", url.toString()); -} - -function urlToStateObject(url: URL): HistoryState { - const historyState: HistoryState = {}; - url.searchParams.forEach((value, key) => { - historyState[key] = value; - }); - return historyState; -} - -/** - * Just like useUrlState(), but typed for storing a boolean - */ -export function useBoolUrlState( - paramName: string, - paramDefault: boolean -): [boolean, () => void] { - const [state, setState] = useUrlState(paramName, paramDefault ? "1" : "0"); - - const toggleBoolState = useCallback(() => { - const newState = state == "0" ? "1" : "0"; - console.log(`Toggling ${paramName} from ${state} to ${newState}`); - setState(newState); - }, [state]); - - return [state === "1", toggleBoolState]; -} - -/** - * Just like useUrlState(), but typed for storing an array of strings - */ -export function useArrayUrlState( - paramName: string, - paramDefault: string[] -): [string[], (state: string[]) => void] { - const [state, setState] = useUrlState(paramName, paramDefault.join(",")); - - const setArrayState = useCallback( - (newState: string[]) => { - setState(newState.join(",")); - }, - [setState] - ); - - // useMemo() is here so that downstream callers can depend on the array - // staying the same referentially if the state hasn't changed. - const valueArray = useMemo(() => (state ? state.split(",") : []), [state]); - - return [valueArray, setArrayState]; -} diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts deleted file mode 100644 index 36baabdf..00000000 --- a/frontend/src/utils.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { DateString, PullData, StatusState, CommitStatus } from "./types"; - -export function actionMessage( - action: string, - date: DateString | null, - user: string -): string { - return date - ? `${action} on ${formatDate(date)} by ${user}` - : `${action} by ${user}`; -} - -export function formatDate(date: DateString) { - return new Date(date).toLocaleDateString([], { - month: "short", - day: "numeric", - }); -} - -export function toDateString(date: Date): DateString { - return date.toISOString(); -} - -export function userProfileUrl(username: string): string { - const debottedName = username.replace(/\[bot\]$/, ""); - const safeUsername = encodeURIComponent(debottedName); - return `https://github.com/${safeUsername}`; -} - -export const dummyPulls: PullData[] = fakeCIStatuses( - (process.env.DUMMY_PULLS || []) as PullData[] -); -export function hasDummyPulls() { - return dummyPulls.length > 0; -} - -export function newTab(url: string) { - window.open(url, "_blank"); -} - -function fakeCIStatuses(pulls: PullData[]) { - const fakeContexts = [ - "psalm", - "deploy", - "unit-tests", - "api", - "integration", - "browser", - "bundle-analysis", - ]; - pulls.forEach((pull) => { - pull.status.commit_statuses = fakeContexts.map((context) => { - const now = unix(); - const startedAt = now - Math.random() * 120 * 60; - const duration = Math.random() * 10 * 60; - const completedAt = startedAt + duration; - return { - data: { - sha: "fake sha", - target_url: "https://example.com", - description: context, - context: context, - state: completedAt > now ? StatusState.pending : finishedState(), - started_at: startedAt, - completed_at: completedAt > now ? null : completedAt, - }, - }; - }); - }); - return pulls; -} - -function finishedState() { - const r = Math.random(); - return r < 0.01 - ? StatusState.error - : r < 0.03 - ? StatusState.failure - : StatusState.success; -} - -function unix() { - return Date.now() / 1000; -} - -export function useDurationMinutes(status: CommitStatus) { - const { started_at, completed_at } = status.data; - if (!started_at) { - return null; - } - - const completed = completed_at || Date.now() / 1000; - const min = (completed - started_at) / 60; - return min > 0 ? min : null; -} diff --git a/frontend/test/named-pulls.ts b/frontend/test/named-pulls.ts deleted file mode 100644 index 542ce12e..00000000 --- a/frontend/test/named-pulls.ts +++ /dev/null @@ -1,532 +0,0 @@ -import { getUser } from "../src/page-context"; -import { PullData, SignatureType } from "../src/types"; -import { label, status, sig, daysAgo, pullData } from "./pull-data-parts"; - -export const AgePulls = [ - pullData({ - title: "Brand new pull", - }), - pullData({ - title: "4 day old pull", - created_at: daysAgo(4), - }), - pullData({ - title: "20 day old pull", - created_at: daysAgo(20), - }), -]; - -export const LinesChanged = [ - pullData({ - title: "Pull card with # of lines changed", - additions: 248, - deletions: 50, - }), -]; - -export const UnfulfilledRequirements = [ - pullData({ - title: "No Requirements", - cr_req: 0, - qa_req: 0, - }), - pullData({ - title: "3 CR Required but no QA", - cr_req: 3, - qa_req: 0, - }), - pullData({ - title: "3 CR Required and 2 QA", - cr_req: 3, - qa_req: 2, - }), -]; - -const activeCR = sig(SignatureType.CR, true); -const activeQA = sig(SignatureType.QA, true); -export const PartialRequirements = [ - pullData({ - title: "1 CR and 1 QA", - status: { - allQA: [activeQA], - allCR: [activeCR], - }, - }), - pullData({ - title: "1 CR and 1 QA, but higher requirements", - cr_req: 3, - qa_req: 2, - status: { - allQA: [activeQA], - allCR: [activeCR], - }, - }), - pullData({ - title: "1 CR and 1 QA, but no requirements", - cr_req: 0, - qa_req: 0, - status: { - allQA: [activeQA], - allCR: [activeCR], - }, - }), -]; - -export const Signatures = [ - pullData({ - title: "1 Out-of-date CR and 1 out-of-date QA by the same user", - status: { - allQA: [sig(SignatureType.QA, false, "user1")], - allCR: [sig(SignatureType.CR, false, "user1")], - }, - }), - pullData({ - title: - "1 CR and 1 out-of-date CR by the same user. 1 QA and 1 out-of-date QA by the same user", - status: { - allQA: [ - sig(SignatureType.QA, true, "user1"), - sig(SignatureType.QA, false, "user1"), - ], - allCR: [ - sig(SignatureType.CR, false, "user1"), - sig(SignatureType.CR, true, "user1"), - ], - }, - }), - pullData({ - title: "Many active CRs and inactive CRs by the same user", - status: { - allCR: [ - sig(SignatureType.CR, false, "user1"), - sig(SignatureType.CR, false, "user1"), - sig(SignatureType.CR, false, "user1"), - sig(SignatureType.CR, false, "user1"), - sig(SignatureType.CR, false, "user1"), - sig(SignatureType.CR, true, "user1"), - sig(SignatureType.CR, true, "user1"), - sig(SignatureType.CR, true, "user1"), - sig(SignatureType.CR, true, "user1"), - sig(SignatureType.CR, true, "user1"), - ], - }, - }), -]; - -export const ManySignatures = [ - pullData({ - title: - "1 out-of-date QA by me, 1 active and 3 out of date CRs, with one by me", - status: { - allQA: [sig(SignatureType.QA, false, "user1")], - allCR: [ - sig(SignatureType.CR, true, "user8"), - sig(SignatureType.CR, false, "user9"), - sig(SignatureType.CR, false, "user10"), - sig(SignatureType.CR, false, getUser()), - ], - }, - }), - pullData({ - title: - "3 active QAs, 5 active CRs and 5 out of date CRs by different users", - status: { - allQA: [ - sig(SignatureType.QA, true, "user8"), - sig(SignatureType.QA, true, "user9"), - sig(SignatureType.QA, true, "user10"), - ], - allCR: [ - sig(SignatureType.CR, false, "user1"), - sig(SignatureType.CR, false, "user2"), - sig(SignatureType.CR, false, "user3"), - sig(SignatureType.CR, false, "user4"), - sig(SignatureType.CR, false, "user5"), - sig(SignatureType.CR, true, "user6"), - sig(SignatureType.CR, true, "user7"), - sig(SignatureType.CR, true, "user8"), - sig(SignatureType.CR, true, "user9"), - sig(SignatureType.CR, true, "user10"), - ], - }, - }), - pullData({ - title: "1 active CR and 5 out of date CRs by different users", - status: { - allQA: [], - allCR: [ - sig(SignatureType.CR, false, "user1"), - sig(SignatureType.CR, false, "user2"), - sig(SignatureType.CR, false, "user3"), - sig(SignatureType.CR, false, "user4"), - sig(SignatureType.CR, false, "user5"), - sig(SignatureType.CR, true, "user6"), - ], - }, - }), -]; -const activeCR2 = sig(SignatureType.CR, true); -const activeQA2 = sig(SignatureType.QA, true); -export const FulfilledRequirements = [ - pullData({ - title: "1 CR and 1 QA with a 1,1 requirement", - cr_req: 1, - qa_req: 1, - status: { - allQA: [activeQA], - allCR: [activeCR], - }, - }), - pullData({ - title: "2 CR and 2 QA but 2,1 is required", - cr_req: 2, - qa_req: 1, - status: { - allQA: [activeQA, activeQA2], - allCR: [activeCR, activeCR2], - }, - }), - pullData({ - title: - "2 active and 1 out of date CR where only 2 are required. QA has 2 active, 1 out of date and 1 required", - cr_req: 2, - qa_req: 1, - status: { - allQA: [activeQA, activeQA2, sig(SignatureType.QA, false)], - allCR: [activeCR, activeCR2, sig(SignatureType.CR, false)], - }, - }), -]; - -export const FewStatuses = [ - pullData({ - title: "Pending status with no log url", - status: { - commit_statuses: [status("pending", null)], - }, - }), - pullData({ - title: "One error commit status", - status: { - commit_statuses: [status("error")], - }, - }), - pullData({ - title: "One successful and one failed commit status", - status: { - commit_statuses: [status("success"), status("failure")], - }, - }), -]; - -const repoSpec = { name: "some/repo", requiredStatuses: ["unit-tests"] }; -export const RequiredStatuses = [ - pullData({ - repoSpec, - title: "Required 'unit-tests' status with it being a failure", - status: { - commit_statuses: [status("failure", null, "unit-tests")], - }, - }), - pullData({ - repoSpec, - title: "Required 'unit-tests' status with a second non-required status", - status: { - commit_statuses: [status("success")], - }, - }), - pullData({ - repoSpec, - title: "Required 'unit-tests' status with no actual statuses", - status: { - commit_statuses: [], - }, - }), -]; - -export const ManyStatuses = [ - pullData({ - title: "Three commit statuses", - status: { - commit_statuses: [status("pending"), status("success"), status("error")], - }, - }), - pullData({ - title: "Six commit statuses", - status: { - commit_statuses: [ - status("error"), - status("failure"), - status("success"), - status("pending"), - status("success"), - status("error"), - ], - }, - }), - pullData({ - title: "Ten commit statuses", - status: { - commit_statuses: [ - status("success"), - status("error"), - status("success"), - status("pending"), - status("success"), - status("failure"), - status("success"), - status("failure"), - status("success"), - status("success"), - ], - }, - }), -]; - -const devBlock = sig(SignatureType.dev_block, true); -const deployBlock = sig(SignatureType.deploy_block, true); -export const Blocked = [ - pullData({ - title: "Deploy Blocked", - status: { - deploy_block: [deployBlock], - }, - }), - pullData({ - title: "Dev Blocked", - status: { - dev_block: [devBlock], - }, - }), - pullData({ - title: "Merge Conflict(s)", - mergeable: false, - }), - pullData({ - title: "Awaiting a merge", - base: { - ref: "not-main", - }, - }), -]; - -export const Milestones = [ - pullData({ - title: "With a future Milestone", - milestone: { - title: "Some big project", - due_on: daysAgo(-10), - }, - }), - pullData({ - title: "With a past Milestone", - milestone: { - title: "Some overdue project", - due_on: daysAgo(10), - }, - }), -]; - -export const Labels = [ - pullData({ - title: "With a QAing Label applied 1 hour ago", - labels: [ - label({ - title: "QAing", - user: "someUser", - created_at: daysAgo(1 / 24), - }), - ], - }), - pullData({ - title: "With an external_block Label applied 1 day ago", - labels: [ - label({ - title: "external_block", - user: "someOtherUser", - created_at: daysAgo(1), - }), - ], - }), - pullData({ - title: 'With a "Cryogenic Storage" Label applied 10 days ago', - labels: [ - label({ - title: "Cryogenic Storage", - user: "someCryoUser", - created_at: daysAgo(10), - }), - ], - }), -]; - -export const Draft = [ - pullData({ - title: "Draft", - draft: true, - }), -]; - -export const MyOwn = [ - pullData({ - title: "Pull Created By Me", - user: { - login: getUser(), - }, - }), - pullData({ - title: "With an up-to-date CR and a QA by Me", - status: { - allQA: [sig(SignatureType.QA, true, getUser())], - allCR: [sig(SignatureType.CR, true, getUser())], - }, - }), - pullData({ - title: "With an out-of-date CR and a QA by Me", - status: { - allQA: [sig(SignatureType.QA, false, getUser())], - allCR: [sig(SignatureType.CR, false, getUser())], - }, - }), -]; - -export const Participants = [ - pullData({ - number: 1, - title: "No participants", - cr_req: 0, - participants: [], - }), - pullData({ - number: 2, - title: "One participant", - cr_req: 0, - participants: ["Other"], - }), - pullData({ - number: 3, - title: "Multiple participants", - cr_req: 0, - participants: ["User2", "User3"], - }), - pullData({ - number: 4, - title: "Only me participating", - cr_req: 0, - participants: [getUser()], - }), - pullData({ - number: 5, - title: "multiple participants (including me)", - cr_req: 0, - participants: ["User2", "User3", getUser()], - }), -]; - -export const Authors = [ - pullData({ - number: 1, - title: "Single author (not me)", - cr_req: 0, - user: { login: "author1" }, - }), - pullData({ - number: 2, - title: "Author plus two assignees (none is me) — hover lists all", - cr_req: 0, - user: { login: "author1" }, - assignees: ["assignee1", "assignee2"], - }), - pullData({ - number: 3, - title: "Authored by me", - cr_req: 0, - user: { login: getUser() }, - }), - pullData({ - number: 4, - title: "Assigned to me (I'm an assignee, not the author)", - cr_req: 0, - user: { login: "author1" }, - assignees: [getUser()], - }), -]; - -export const ReviewRequests = [ - pullData({ - number: 1, - title: "Review requested from me", - cr_req: 1, - requested_reviewers: [getUser()], - }), - pullData({ - number: 1, - title: "Review requested from me", - cr_req: 1, - requested_reviewers: [getUser()], - status: { - allQA: [], - allCR: [sig(SignatureType.CR, true, getUser())], - }, - }), - pullData({ - number: 2, - title: "Review requested from me and another", - cr_req: 1, - requested_reviewers: [getUser(), "User 2"], - status: { - allQA: [], - allCR: [sig(SignatureType.CR, true, "User 2")], - }, - }), - pullData({ - number: 3, - title: "Review requested from someone else", - cr_req: 1, - requested_reviewers: ["User2"], - }), -]; - -export const KitchenSink = [ - pullData({ - title: "Pull With Lots of flags and such and a really long title", - user: { - login: getUser(), - }, - base: { - ref: "not-main", - }, - additions: 120, - deletions: 20, - mergeable: false, - draft: true, - cr_req: 3, - qa_req: 2, - status: { - deploy_block: [deployBlock], - dev_block: [devBlock], - commit_statuses: [ - status("success"), - status("error"), - status("success"), - status("pending"), - status("success"), - status("failure"), - status("success"), - status("failure"), - status("success"), - status("success"), - ], - }, - labels: [ - label({ - title: "QAing", - user: "someUser", - created_at: daysAgo(1 / 24), - }), - ], - participants: ["other person", getUser()], - assignees: ["coAuthor"], - requested_reviewers: [getUser()], - }), -]; diff --git a/frontend/test/pull-card-demo.tsx b/frontend/test/pull-card-demo.tsx deleted file mode 100644 index 07031e1d..00000000 --- a/frontend/test/pull-card-demo.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Pull } from "../src/pull"; -import { PullData } from "../src/types"; -import { ChakraProvider, Box, HStack, Heading } from "@chakra-ui/react"; -import { - AgePulls, - LinesChanged, - UnfulfilledRequirements, - PartialRequirements, - FulfilledRequirements, - Signatures, - ManySignatures, - FewStatuses, - RequiredStatuses, - ManyStatuses, - Blocked, - Milestones, - Labels, - Draft, - MyOwn, - Authors, - ReviewRequests, - Participants, - KitchenSink, -} from "./named-pulls"; -import { PullCard } from "../src/pull-card"; -import { render } from "react-dom"; -import { theme } from "../src/theme"; - -const root = document.createElement("div"); -document.body.appendChild(root); - -function PullCardDemo() { - return ( - - - - - - - - - - - - - - - - - - - - - - ); -} - -function Row({ title, pullDatas, linesChanged }: { title: string; pullDatas: PullData[]; linesChanged?: boolean }) { - return ( - <> - - {title} - - {pullDatas.map((pullData, i) => ( - - - - ))} - - - - ); -} - -render(, root); diff --git a/frontend/test/pull-data-parts.ts b/frontend/test/pull-data-parts.ts deleted file mode 100644 index 5ead8a1a..00000000 --- a/frontend/test/pull-data-parts.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { - PullData, - Signature, - SignatureType, - StatusState, - CommitStatus, - Label, -} from "../src/types"; - -const repo = "iFixit/ifixit"; - -export const head = { - ref: "some-branch-name", - sha: "ee18a55031b8b1e86dbe240552dfe86f344559d7", - repo: { - owner: { - login: "iFixit", - }, - }, -}; - -export function daysAgo(days: number): string { - return new Date(Date.now() - days * 86400 * 1000).toString(); -} - -export function label(label: Partial
+ your turn} + def="a starved review nobody’s on gets pointed at one person so it doesn’t sit forever — you can still pass it to anyone" + /> +
+ + + › + + How your turn is picked + +
+

+ When a PR has gone too long without enough CR and nobody has claimed it, the + board assigns it to one reviewer so it stops falling through the cracks. Every + client picks the same name with no coordination: it hashes the PR’s repo and + number onto one person from the pool who’ve CR’d that repo before, skipping the + author and anyone who already stamped this one. +

+

+ It’s a nudge, not a lock — anyone can take it, and an explicit GitHub review + request overrides the guess entirely. +

+
+
From ae2a0ad6983e41a362eae3928979010b49c112e4 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 17:03:51 -0700 Subject: [PATCH 194/381] Add notification settings: dwell, bell badge, and an explainer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Notifications group had a single on/off for cheers & nudges and no account of what they actually are — the same gap the legend closed for regions, claims, and the turn rotation. Bring that documentation into Settings and give people the two knobs they'd reach for next. - An "What fires a cheer or a nudge" disclosure, matching the legend's collapsible style, splitting the catalog into rewards vs. what-to-review nudges so the feature is legible without reading the source. - "How long they stay": a Brief/Normal/Relaxed multiplier on the per-tone toast TTLs, preserving the ratio (a nag still outsits a reward) while letting someone speed them up or let them breathe. - "Bell badge": show the unseen count, a bare dot, or nothing on the recent-nudges bell. Both new fields default to today's behavior (normal dwell, count badge), so an existing saved blob reads unchanged. Co-Authored-By: Claude Opus 4.8 --- .../src/components/NotificationPanel.tsx | 16 +++-- frontend-v2/src/components/Settings.tsx | 64 +++++++++++++++++++ frontend-v2/src/settings.ts | 8 +++ frontend-v2/src/toasts.tsx | 11 +++- 4 files changed, 93 insertions(+), 6 deletions(-) diff --git a/frontend-v2/src/components/NotificationPanel.tsx b/frontend-v2/src/components/NotificationPanel.tsx index 6bfdf47f..db2016c6 100644 --- a/frontend-v2/src/components/NotificationPanel.tsx +++ b/frontend-v2/src/components/NotificationPanel.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { ago, githubUrl } from '../format'; +import { useSettings } from '../settings'; import type { ToastRecord } from '../toasts'; import { onOpen, Popover } from './Popover'; @@ -36,6 +37,8 @@ export function NotificationPanel({ // nothing to persist. Everything fired after this counts as unseen. const [lastSeen, setLastSeen] = useState(0); const unseen = records.filter(r => r.at > lastSeen).length; + const badge = useSettings().notifyBadge; + const flag = unseen > 0 && badge !== 'none'; return ( - {unseen > 0 && ( - - {unseen > 9 ? '9+' : unseen} - - )} + {flag && + (badge === 'dot' ? ( + + ) : ( + + {unseen > 9 ? '9+' : unseen} + + ))} )} > diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index dbd5a66e..cd25e2d8 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -43,6 +43,23 @@ function Group({ title, children }: { title: string; children: ReactNode }) { ); } +/** A collapsible "how it works" note, mirroring the legend's disclosures so the + * explanation reads the same wherever it appears. Closed by default: it's there + * when you go looking, not in the way when you aren't. */ +function Explainer({ summary, children }: { summary: string; children: ReactNode }) { + return ( +
+ + + › + + {summary} + +
{children}
+
+ ); +} + /** One setting: a label (+ optional hint) over its control. */ function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { return ( @@ -468,6 +485,53 @@ export function Settings({ onChange={v => set({ cheers: v === 'on' })} /> + +

+ Cheers are the rewards: a + CR of yours lands, a PR you touched goes green, you top the board, or + the whole board goes clear. A hero cheer throws a few sparks. +

+

+ Nudges point you at work: + the quick wins waiting, a review someone requested of you, a favor to + return, your turn in the rotation, a PR that changed since your ✓, or + a claim of yours going stale. +

+

+ They flash in the corner while you’re on the board and fade on their + own — nothing sticks. The bell keeps the recent ones if you miss one. +

+
+ + set({ cheerDwell })} + /> + + + set({ notifyBadge })} + /> + {notificationsSupported ? ( <> = { reward: 5000, nag: 7000, info: 9000 }; +/** Multiplier on the base TTLs, set by the "How long they stay" preference — + * keeps the tone ratios intact while letting someone speed them up or let them + * breathe. */ +const DWELL: Record<'brief' | 'normal' | 'relaxed', number> = { + brief: 0.6, + normal: 1, + relaxed: 1.6, +}; /** Time for the leave animation before the node is removed. */ const LEAVE_MS = 200; /** Most toasts on screen at once; a fourth pushes the oldest out early. */ @@ -195,10 +203,11 @@ export function useToasts( timers.current.delete(oldest.id); merged = rest; } + const dwell = DWELL[getSettings().cheerDwell]; for (const t of added) { timers.current.set( t.id, - setTimeout(() => dismiss(t.id), t.ttlMs ?? TTL_MS[t.tone]) + setTimeout(() => dismiss(t.id), t.ttlMs ?? TTL_MS[t.tone] * dwell) ); } return merged; From 75a9086bbc26caa1634ba1c558162cbb5f4edd02 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 17:12:52 -0700 Subject: [PATCH 195/381] Replace the cheers prose with per-kind toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Notifications explainer was a single collapsible paragraph — it told you cheers and nudges existed but gave no control over which ones fire. Swap it for the real thing: a switch per kind, each with its own one-line explanation, so the documentation and the control are the same list. - CHEER_CATALOG in model/cheers enumerates every toast kind with a group (Rewards / Nudges / Your PRs), a label, and a blurb. A test pins it 1:1 with PRIORITY_ORDER so a new kind can't ship without a switch and copy. - diffCheers takes a `muted` set and drops those kinds before the priority sort and per-tick cap — so muting a high-priority kind frees its slot for a shown one instead of firing then hiding it. The tally and "already seen" bookkeeping still runs, so unmuting never replays a backlog. Threaded through from the mutedCheers setting. - A compact Switch primitive in bits, and the grouped toggle list in the Settings Notifications group, gated (disabled) by the master switch. mutedCheers defaults to empty (everything on), so existing prefs are unchanged. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Settings.tsx | 61 +++++++--- frontend-v2/src/components/bits.tsx | 34 ++++++ frontend-v2/src/model/cheers.test.ts | 54 +++++++++ frontend-v2/src/model/cheers.ts | 155 +++++++++++++++++++++++- frontend-v2/src/settings.ts | 13 ++ frontend-v2/src/toasts.tsx | 2 + 6 files changed, 296 insertions(+), 23 deletions(-) diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index cd25e2d8..c1b95141 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -8,6 +8,7 @@ import { testNotification, unlockSound, } from '../notifications'; +import { CHEER_CATALOG, type CheerGroup } from '../model/cheers'; import { clearSnoozes, markAllSeen, refreshAll, usePulldasher } from '../store'; import { addCodeRegion, @@ -15,10 +16,11 @@ import { type Settings as SettingsShape, setRepoPref, setSettings, + toggleCheerKind, togglePrimaryRepo, useSettings, } from '../settings'; -import { QuietButton, Segmented } from './bits'; +import { QuietButton, Segmented, Switch } from './bits'; import { RepoManagerGroup } from './RepoManager'; import { TeamPickerGroup } from './TeamPicker'; @@ -60,6 +62,45 @@ function Explainer({ summary, children }: { summary: string; children: ReactNode ); } +const CHEER_GROUPS: { group: CheerGroup; title: string }[] = [ + { group: 'reward', title: 'Rewards' }, + { group: 'nudge', title: 'Nudges' }, + { group: 'author', title: 'Your PRs' }, +]; + +/** The per-kind list: every cheer/nudge, grouped, each a one-line explanation + * with its own switch. The master "Cheers & nudges" toggle still gates the + * whole system; these pick which kinds fire when it's on, so they read as + * disabled while it's off. */ +function CheerToggles({ disabled }: { disabled: boolean }) { + const muted = new Set(useSettings().mutedCheers); + return ( +
+ {CHEER_GROUPS.map(({ group, title }) => ( +
+ + {title} + + {CHEER_CATALOG.filter(c => c.group === group).map(c => ( +
+ + {c.label} + {c.hint} + + toggleCheerKind(c.kind, next)} + /> +
+ ))} +
+ ))} +
+ ); +} + /** One setting: a label (+ optional hint) over its control. */ function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { return ( @@ -485,22 +526,8 @@ export function Settings({ onChange={v => set({ cheers: v === 'on' })} />
- -

- Cheers are the rewards: a - CR of yours lands, a PR you touched goes green, you top the board, or - the whole board goes clear. A hero cheer throws a few sparks. -

-

- Nudges point you at work: - the quick wins waiting, a review someone requested of you, a favor to - return, your turn in the rotation, a PR that changed since your ✓, or - a claim of yours going stale. -

-

- They flash in the corner while you’re on the board and fade on their - own — nothing sticks. The bell keeps the recent ones if you miss one. -

+ + ({ ); } +/** A compact on/off switch — a track with a sliding knob. Used where a full + * Segmented (On/Off) would be too heavy, e.g. a long list of per-item toggles. */ +export function Switch({ + checked, + onChange, + ariaLabel, + disabled, +}: { + checked: boolean; + onChange: (next: boolean) => void; + ariaLabel: string; + disabled?: boolean; +}) { + return ( + + ); +} + export function EmptyState({ title, sub }: { title: string; sub: string }) { return (
diff --git a/frontend-v2/src/model/cheers.test.ts b/frontend-v2/src/model/cheers.test.ts index f0f19382..4da578f3 100644 --- a/frontend-v2/src/model/cheers.test.ts +++ b/frontend-v2/src/model/cheers.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest'; import { pullKey } from '../format'; import { type AuthorPrState, + CHEER_CATALOG, type CheerBaseline, diffCheers, EMPTY_BASELINE, + PRIORITY_ORDER, type Signals, startHereReason, + type ToastKind, } from './cheers'; import type { DerivedPull, Weight } from './status'; @@ -584,3 +587,54 @@ describe('diffCheers — MAX_PER_TICK', () => { expect(toasts.some(t => t.icon === '⚡')).toBe(false); }); }); + +describe('cheer catalog', () => { + it('documents exactly every toast kind, once each', () => { + const catalogKinds = CHEER_CATALOG.map(c => c.kind); + // no dupes, and 1:1 with the priority list the evaluator fires from — so + // a new kind can't ship without a switch and a blurb in the catalog + expect(new Set(catalogKinds).size).toBe(catalogKinds.length); + expect(new Set(catalogKinds)).toEqual(new Set(PRIORITY_ORDER)); + }); +}); + +describe('diffCheers — muting', () => { + it('drops a muted kind, but fires it when not muted', () => { + const base = primed(sig()); + const snap = () => sig({ quickWinCount: 4, quickWinPull: pull('org/a', 1) }); + + const shown = diffCheers(snap(), 'me', base); + expect(shown.toasts.some(t => t.dedupeKey?.startsWith('quick:'))).toBe(true); + + const hidden = diffCheers(snap(), 'me', base, new Set(['quick-wins'])); + expect(hidden.toasts.some(t => t.dedupeKey?.startsWith('quick:'))).toBe(false); + }); + + it('muting frees a per-tick slot for a lower-priority kind', () => { + // four fresh nudges compete for three slots; quick-wins (lowest) loses. + // mute the top one (review-requested) and quick-wins gets through. + const base = primed(sig()); + const p1 = pull('org/a', 1, { ageDays: 3 }); + const p2 = pull('org/a', 2, { ageDays: 4 }); + const p3 = pull('org/a', 3, { ageDays: 5 }); + const build = (muted?: ReadonlySet) => + diffCheers( + sig({ + restampKeys: new Set([pullKey(p1.data)]), // re-stamp-owed + turns: new Map([[pullKey(p2.data), p2]]), // your-turn + requestedOfMe: new Map([[pullKey(p3.data), p3]]), // review-requested (top) + quickWinCount: 4, // quick-wins (lowest) + pulls: [p1, p2, p3], + }), + 'me', + base, + muted + ); + const shown = build(); + expect(shown.toasts).toHaveLength(3); + expect(shown.toasts.some(t => t.icon === '⚡')).toBe(false); + const freed = build(new Set(['review-requested'])); + expect(freed.toasts.some(t => t.dedupeKey?.startsWith('req:'))).toBe(false); + expect(freed.toasts.some(t => t.icon === '⚡')).toBe(true); + }); +}); diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index 6a2deb6a..0403d20b 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -106,6 +106,10 @@ export interface CheerInput { /** how long a claim of yours may sit before the stale-claim nag fires (ms); * the viewer's claim-warning setting, falling back to STALE_CLAIM_MS */ claimWarnMs?: number; + /** cheer kinds the viewer has switched off — filtered before the per-tick + * cap, so muting a high-priority kind frees its slot for a shown one rather + * than firing then hiding it. */ + muted?: ReadonlySet; } /** A claim you're sitting on stops absolving other reviewers at 2h (see @@ -384,12 +388,12 @@ export function evaluateCheers( input: CheerInput, base: CheerBaseline ): { toasts: CheerToast[]; next: CheerBaseline } { - return diffCheers(readSignals(input), input.me, base); + return diffCheers(readSignals(input), input.me, base, input.muted); } /** Toast categories, highest priority first — what survives when a tick * overflows MAX_PER_TICK. */ -const PRIORITY_ORDER = [ +export const PRIORITY_ORDER = [ 'board-cleared', 'inbox-zero', 'pr-green', @@ -410,11 +414,144 @@ const PRIORITY_ORDER = [ 'pr-conflicts', 'pr-starving', ] as const; -type ToastKind = (typeof PRIORITY_ORDER)[number]; +export type ToastKind = (typeof PRIORITY_ORDER)[number]; const PRIORITY: Record = Object.fromEntries( PRIORITY_ORDER.map((kind, i) => [kind, PRIORITY_ORDER.length - i]) ) as Record; +/** The three families a cheer/nudge falls into, for grouping the Settings + * toggle list: rewards you earn, nudges toward review work, and alerts about + * your own PRs. */ +export type CheerGroup = 'reward' | 'nudge' | 'author'; + +/** + * User-facing catalog of every cheer/nudge kind — the group it belongs to, plus + * a one-line label and explanation for the per-kind toggle list in Settings. + * Kept exactly 1:1 with PRIORITY_ORDER (a test asserts the two never drift), so + * a new toast kind can't be added without also giving it a switch and a blurb. + */ +export const CHEER_CATALOG: { + kind: ToastKind; + group: CheerGroup; + label: string; + hint: string; +}[] = [ + // Rewards — the good news + { + kind: 'stamp-landed', + group: 'reward', + label: 'Review landed', + hint: 'A CR or QA stamp of yours lands.', + }, + { + kind: 'milestone', + group: 'reward', + label: 'Review streak', + hint: 'You pass a round number of reviews this sitting.', + }, + { + kind: 'inbox-zero', + group: 'reward', + label: 'Inbox zero', + hint: 'Your own review queue drops to nothing.', + }, + { + kind: 'board-cleared', + group: 'reward', + label: 'Board’s clear', + hint: 'Nothing on the whole board is waiting on anyone.', + }, + { + kind: 'top-of-board', + group: 'reward', + label: 'Top reviewer', + hint: 'You reach the top of the board’s leaderboard.', + }, + { + kind: 'climbing', + group: 'reward', + label: 'Climbing the ranks', + hint: 'You pass someone on the leaderboard.', + }, + { + kind: 'pr-green', + group: 'reward', + label: 'Your PR is green', + hint: 'A PR of yours clears CR and QA — ready to ship.', + }, + // Nudges — what to review next + { + kind: 'start-here', + group: 'nudge', + label: 'Start here', + hint: 'The single best pull to pick up next.', + }, + { + kind: 'quick-wins', + group: 'nudge', + label: 'Quick wins', + hint: 'A pile of small, unclaimed reviews worth a pass.', + }, + { + kind: 'review-requested', + group: 'nudge', + label: 'Review requested', + hint: 'Someone asked for your review on GitHub.', + }, + { + kind: 'your-turn', + group: 'nudge', + label: 'Your turn', + hint: 'The rotation named you on a starved review.', + }, + { + kind: 're-stamp-owed', + group: 'nudge', + label: 'Changed since your ✓', + hint: 'A PR you approved changed and wants another look.', + }, + { + kind: 'return-the-favor', + group: 'nudge', + label: 'Return the favor', + hint: 'Someone who reviewed your PRs has one open.', + }, + { + kind: 'claim-stale', + group: 'nudge', + label: 'Stale claim', + hint: 'A review you claimed has sat unreviewed too long.', + }, + // Your PRs — author-side alerts + { + kind: 'pr-first-review', + group: 'author', + label: 'Picked up', + hint: 'A reviewer started on your PR.', + }, + { kind: 'pr-conflicts', group: 'author', label: 'Conflicts', hint: 'Your PR needs a rebase.' }, + { + kind: 'pr-ci-red', + group: 'author', + label: 'CI broke', + hint: 'A required check went red on your PR.', + }, + { + kind: 'pr-changes', + group: 'author', + label: 'Changes requested', + hint: 'A reviewer wants edits on your PR.', + }, + { + kind: 'pr-starving', + group: 'author', + label: 'Waiting too long', + hint: 'Your PR has gone a while with no review.', + }, +]; + +const NO_MUTED: ReadonlySet = new Set(); + /** * Diff two board snapshots into the toasts worth firing right now, plus the * baseline to carry into the next tick. Pure: everything it needs is in `sig`, @@ -430,11 +567,12 @@ const PRIORITY: Record = Object.fromEntries( export function diffCheers( sig: Signals, me: string, - base: CheerBaseline + base: CheerBaseline, + muted: ReadonlySet = NO_MUTED ): { toasts: CheerToast[]; next: CheerBaseline } { if (!base.primed || !me) { const toasts: CheerToast[] = []; - if (me && sig.bestStart && sig.backlog > 0) { + if (me && sig.bestStart && sig.backlog > 0 && !muted.has('start-here')) { toasts.push(startHereToast(sig.bestStart, sig.startReason)); } return { @@ -462,7 +600,12 @@ export function diffCheers( } const entries: { toast: CheerToast; kind: ToastKind }[] = []; - const push = (kind: ToastKind, toast: CheerToast) => entries.push({ toast, kind }); + // a muted kind is dropped here, before the priority sort and cap — the + // bookkeeping around each push (session tallies, "already seen" sets) still + // runs, so unmuting later never replays a backlog. + const push = (kind: ToastKind, toast: CheerToast) => { + if (!muted.has(kind)) entries.push({ toast, kind }); + }; let sessionStamps = base.sessionStamps; const firedMilestones = new Set(base.firedMilestones); diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index 64e86132..ccc85d8b 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -41,6 +41,10 @@ export interface Settings { /** how the recent-nudges bell flags what landed since you last opened it: * the running count, a bare dot, or nothing. */ notifyBadge: 'count' | 'dot' | 'none'; + /** cheer/nudge kinds switched off individually (CHEER_CATALOG keys). A + * mute-list, not an allow-list, so a newly added kind defaults to on. Only + * bites when `cheers` is on — the master switch still gates the whole lot. */ + mutedCheers: string[]; /** rows a lane shows before folding into "+N more"; 0 = no cap (show all) */ laneCap: number; /** teams that self-review (iFixit) don't gate on CR, so lining up QA is the @@ -95,6 +99,7 @@ export const DEFAULT_SETTINGS: Settings = { cheers: true, cheerDwell: 'normal', notifyBadge: 'count', + mutedCheers: [], laneCap: 10, selfReview: true, primaryRepos: [], @@ -171,6 +176,14 @@ export function removeCodeRegion(region: string) { setSettings({ codeRegions: store.get().codeRegions.filter(r => r !== region) }); } +/** Switch one cheer/nudge kind on or off. `on` adds it back (drops it from the + * mute-list); off mutes it. */ +export function toggleCheerKind(kind: string, on: boolean) { + const cur = store.get().mutedCheers; + const next = on ? cur.filter(k => k !== kind) : [...new Set([...cur, kind])]; + setSettings({ mutedCheers: next }); +} + export function useSettings(): Settings { return store.useValue(); } diff --git a/frontend-v2/src/toasts.tsx b/frontend-v2/src/toasts.tsx index ab8cdcb6..d83b1d50 100644 --- a/frontend-v2/src/toasts.tsx +++ b/frontend-v2/src/toasts.tsx @@ -5,6 +5,7 @@ import { type CheerToast, EMPTY_BASELINE, evaluateCheers, + type ToastKind, } from './model/cheers'; import type { DerivedPull } from './model/status'; import type { Toast, ToastTone } from './model/toast'; @@ -226,6 +227,7 @@ export function useToasts( claims, now: Date.now(), claimWarnMs: getSettings().claimWarnMins * 60_000, + muted: new Set(getSettings().mutedCheers as ToastKind[]), }, baseline.current ); From 9eda6f66aa2273838a9db21749523216ff3fa38f Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 17:19:16 -0700 Subject: [PATCH 196/381] Make toast dwell an explicit duration, with a sticky option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brief/Normal/Relaxed was a vague multiplier over per-tone base times — you couldn't tell what any of them meant in seconds, and there was no way to make a toast wait for you. Replace it with a plain list of durations (3s / 5s / 8s / 12s) plus Sticky, which fires no auto-dismiss timer at all: the toast stays until you dismiss it or a newer one pushes it off the top of the (still capped) stack. cheerDwell is now a number of ms, or 'sticky'. A defensive typeof guard treats any non-number as no-timer, so a stale enum value from an older build can never reach setTimeout as NaN and dismiss everything instantly. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Settings.tsx | 16 ++++++++----- frontend-v2/src/settings.ts | 9 +++---- frontend-v2/src/toasts.tsx | 32 +++++++++++-------------- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index c1b95141..e2c8ab5f 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -531,17 +531,21 @@ export function Settings({ set({ cheerDwell })} + onChange={v => + set({ cheerDwell: v === 'sticky' ? 'sticky' : Number(v) }) + } /> = { reward: 5000, nag: 7000, info: 9000 }; -/** Multiplier on the base TTLs, set by the "How long they stay" preference — - * keeps the tone ratios intact while letting someone speed them up or let them - * breathe. */ -const DWELL: Record<'brief' | 'normal' | 'relaxed', number> = { - brief: 0.6, - normal: 1, - relaxed: 1.6, -}; /** Time for the leave animation before the node is removed. */ const LEAVE_MS = 200; /** Most toasts on screen at once; a fourth pushes the oldest out early. */ @@ -204,12 +192,20 @@ export function useToasts( timers.current.delete(oldest.id); merged = rest; } - const dwell = DWELL[getSettings().cheerDwell]; + // 'sticky' means no auto-dismiss timer: the toast waits for a + // manual dismiss (or eviction once the stack overflows the cap). + const dwell = getSettings().cheerDwell; + // a number auto-dismisses after that many ms; 'sticky' (or any + // unexpected legacy value) means no timer at all + const auto = typeof dwell === 'number' ? dwell : null; for (const t of added) { - timers.current.set( - t.id, - setTimeout(() => dismiss(t.id), t.ttlMs ?? TTL_MS[t.tone] * dwell) - ); + const ttl = t.ttlMs ?? auto; + if (ttl != null) { + timers.current.set( + t.id, + setTimeout(() => dismiss(t.id), ttl) + ); + } } return merged; }); From 43bafe11cc14f07e3820b6d1cae83991f540879b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 17:19:16 -0700 Subject: [PATCH 197/381] Drop the "you know this repo" start-here reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a monorepo where most PRs share one repo, "You know ifixit — less to load in" is both always true and no motivation at all: everyone has stamped something in it. The start-here nudge is supposed to give a reason specific to *this* pull, so a vacuous repo-familiarity line undercuts the whole point. Remove it. The reason now falls straight through to what actually motivates a pickup: returning a review favor, a genuinely small diff, or the oldest thing waiting on your plate. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/cheers.test.ts | 14 ++++++++------ frontend-v2/src/model/cheers.ts | 18 +++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/frontend-v2/src/model/cheers.test.ts b/frontend-v2/src/model/cheers.test.ts index 4da578f3..f2a7a264 100644 --- a/frontend-v2/src/model/cheers.test.ts +++ b/frontend-v2/src/model/cheers.test.ts @@ -269,15 +269,17 @@ describe('startHereReason — priority order', () => { ); }); - it('falls back to familiarity when there is no reciprocity', () => { - const target = pull('org/a', 1, { author: 'alice', weight: 'XS' }); - const familiarPull = pull('org/a', 2, { crBy: ['me'] }); - expect(startHereReason(target, [target, familiarPull], 'me')).toBe( - 'You know a — less to load in' + it('does not treat a shared repo as a reason — falls through it', () => { + // an M-weight pull in a repo the viewer has stamped before: no "you know + // this repo" reason, so with no reciprocity or quick win it hits urgency + const target = pull('org/a', 1, { author: 'alice', weight: 'M', ageDays: 3 }); + const sameRepoStamped = pull('org/a', 2, { crBy: ['me'] }); + expect(startHereReason(target, [target, sameRepoStamped], 'me')).toBe( + 'Waiting 3d, the oldest on your plate' ); }); - it('falls back to a quick-win when unfamiliar and no reciprocity', () => { + it('falls back to a quick-win when there is no reciprocity', () => { const target = pull('org/b', 1, { author: 'alice', weight: 'XS', sizeKnown: true }); expect(startHereReason(target, [target], 'me')).toBe('Small one (XS) — quick'); }); diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index 0403d20b..85326ed8 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -1,4 +1,4 @@ -import { pullKey, shortRepo } from '../format'; +import { pullKey } from '../format'; import type { PullData } from '../types'; import { actionState } from './actions'; import { dealOne } from './deal'; @@ -152,19 +152,19 @@ function hasStamp(p: DerivedPull, login: string): boolean { } /** - * The one-line "why this pull" for start-here, in the priority order the spec - * calls for: reciprocity beats familiarity beats a quick win beats plain - * urgency. `pulls` is the whole board (not just the queue) — familiarity and - * reciprocity look across every repo/author the viewer touches, matching - * deal.ts's own scoring. + * The one-line "why this pull" for start-here, in priority order: returning a + * favor beats a quick win beats plain urgency. `pulls` is the whole board (not + * just the queue) so reciprocity can look across every author the viewer has + * reviewed, matching deal.ts's own scoring. + * + * There's deliberately no "you know this repo" reason: in a monorepo everyone + * has stamped something in it, so it's both always true and no motivation at + * all — the reason has to be something specific to *this* pull. */ export function startHereReason(p: DerivedPull, pulls: DerivedPull[], me: string): string { - const repo = p.data.repo; const author = p.data.user.login; const owedByAuthor = pulls.some(o => o.data.user.login === me && hasStamp(o, author)); if (owedByAuthor) return `${author} reviewed yours — return the favor`; - const familiar = pulls.some(o => o.data.repo === repo && hasStamp(o, me)); - if (familiar) return `You know ${shortRepo(repo)} — less to load in`; const quickWin = p.sizeKnown && (p.weight === 'XS' || p.weight === 'S'); if (quickWin) return `Small one (${p.weight}) — quick`; return `Waiting ${Math.max(1, Math.round(p.ageDays))}d, the oldest on your plate`; From 7e25ccb3a71f0ff9488eb0c0fdc348cff1fce0c2 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 17:33:30 -0700 Subject: [PATCH 198/381] Stop reloads from replaying the load-time nudges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refreshing the page re-fired the same greetings every time — "Start here", "Return the favor", the shipped catch-up — because the cheers baseline lived only in a ref and re-primed from EMPTY_BASELINE on each mount. A fresh prime treats the whole board as new, so every load-time toast fired again. Persist the baseline (and the one-shot `extras` dedupe set) in sessionStorage, keyed by viewer. On mount, before any firing effect runs, rehydrate it so a reload resumes the diff against where the last tick left off: only what genuinely changed while away speaks up. sessionStorage, not localStorage, so a brand-new tab still gets its one catch-up; a reload doesn't. The save is skipped while the viewer is empty (whoami resolves a tick after first render), or a me='' write would clobber the real session and defeat the hydrate. serializeBaseline/reviveBaseline flatten the Sets and the Map to JSON and back, tolerating a malformed blob by falling back to a clean prime. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/cheers.test.ts | 18 +++++++ frontend-v2/src/model/cheers.ts | 60 ++++++++++++++++++++++++ frontend-v2/src/toasts.tsx | 70 +++++++++++++++++++++++++++- 3 files changed, 146 insertions(+), 2 deletions(-) diff --git a/frontend-v2/src/model/cheers.test.ts b/frontend-v2/src/model/cheers.test.ts index f2a7a264..3414dfb0 100644 --- a/frontend-v2/src/model/cheers.test.ts +++ b/frontend-v2/src/model/cheers.test.ts @@ -7,6 +7,8 @@ import { diffCheers, EMPTY_BASELINE, PRIORITY_ORDER, + reviveBaseline, + serializeBaseline, type Signals, startHereReason, type ToastKind, @@ -590,6 +592,22 @@ describe('diffCheers — MAX_PER_TICK', () => { }); }); +describe('baseline persistence', () => { + it('round-trips through JSON serialize/revive', () => { + const p = pull('org/a', 1, { author: 'alice' }); + const base = primed(sig({ stamped: new Set(['org/a#1']), queue: 3, pulls: [p], backlog: 2 })); + const round = reviveBaseline(JSON.parse(JSON.stringify(serializeBaseline(base)))); + // deep-equal covers the Sets and the authorPrs Map structurally + expect(round).toEqual(base); + }); + + it('returns null on a malformed blob so the caller falls back to priming', () => { + expect(reviveBaseline(null)).toBeNull(); + expect(reviveBaseline('nope')).toBeNull(); + expect(reviveBaseline(42)).toBeNull(); + }); +}); + describe('cheer catalog', () => { it('documents exactly every toast kind, once each', () => { const catalogKinds = CHEER_CATALOG.map(c => c.kind); diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index 85326ed8..c3d6fa12 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -92,6 +92,66 @@ export const EMPTY_BASELINE: CheerBaseline = { staleClaimsSeen: new Set(), }; +/** + * Flatten a baseline to a JSON-safe shape (Sets → arrays, the Map → entries) + * so it can ride in sessionStorage. Persisting the baseline is what stops a + * page reload from re-priming off EMPTY_BASELINE and replaying every load-time + * greeting (start-here, return-the-favor, the shipped catch-up); a reload + * instead resumes the diff, so only what genuinely changed while away speaks. + */ +export function serializeBaseline(b: CheerBaseline): unknown { + return { + primed: b.primed, + stamped: [...b.stamped], + queue: b.queue, + nagLevel: b.nagLevel, + sessionStamps: b.sessionStamps, + firedMilestones: [...b.firedMilestones], + seenTurns: [...b.seenTurns], + restampKeys: [...b.restampKeys], + quickWinsNagged: b.quickWinsNagged, + favorsSeen: [...b.favorsSeen], + myRank: b.myRank, + wasTop: b.wasTop, + requestedSeen: [...b.requestedSeen], + hadBacklog: b.hadBacklog, + authorPrs: [...b.authorPrs.entries()], + boardQueue: b.boardQueue, + staleClaimsSeen: [...b.staleClaimsSeen], + }; +} + +/** Rebuild a baseline from serializeBaseline's output. Returns null on anything + * malformed, so a corrupt or stale-shaped blob just falls back to priming. */ +export function reviveBaseline(raw: unknown): CheerBaseline | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); + try { + return { + primed: !!o.primed, + stamped: new Set(arr(o.stamped) as string[]), + queue: Number(o.queue) || 0, + nagLevel: Number(o.nagLevel) || 0, + sessionStamps: Number(o.sessionStamps) || 0, + firedMilestones: new Set(arr(o.firedMilestones) as number[]), + seenTurns: new Set(arr(o.seenTurns) as string[]), + restampKeys: new Set(arr(o.restampKeys) as string[]), + quickWinsNagged: !!o.quickWinsNagged, + favorsSeen: new Set(arr(o.favorsSeen) as string[]), + myRank: Number(o.myRank) || 0, + wasTop: !!o.wasTop, + requestedSeen: new Set(arr(o.requestedSeen) as string[]), + hadBacklog: !!o.hadBacklog, + authorPrs: new Map(arr(o.authorPrs) as [string, AuthorPrState][]), + boardQueue: Number(o.boardQueue) || 0, + staleClaimsSeen: new Set(arr(o.staleClaimsSeen) as string[]), + }; + } catch { + return null; + } +} + export interface CheerInput { pulls: DerivedPull[]; /** closed pulls in the loaded window, for the leaderboard's tally — optional diff --git a/frontend-v2/src/toasts.tsx b/frontend-v2/src/toasts.tsx index b40c84d5..aa82e9f3 100644 --- a/frontend-v2/src/toasts.tsx +++ b/frontend-v2/src/toasts.tsx @@ -5,6 +5,8 @@ import { type CheerToast, EMPTY_BASELINE, evaluateCheers, + reviveBaseline, + serializeBaseline, type ToastKind, } from './model/cheers'; import type { DerivedPull } from './model/status'; @@ -14,6 +16,51 @@ import type { PullData } from './types'; /** Time for the leave animation before the node is removed. */ const LEAVE_MS = 200; + +/** + * Per-tab memory of what's already been shown, so reloading the page doesn't + * re-prime from scratch and replay every load-time greeting. sessionStorage + * (not localStorage) on purpose: it survives a reload but not a brand-new tab, + * so a genuinely fresh session still gets its one catch-up. Keyed by viewer so + * switching accounts in the same tab starts clean. + */ +const SESSION_KEY = 'pd2.cheers.session'; + +interface CheerSession { + baseline: CheerBaseline; + /** dedupeKeys of one-shot `extras` (e.g. the shipped catch-up) already fired */ + fired: string[]; +} + +function loadCheerSession(me: string): CheerSession | null { + try { + const raw = sessionStorage.getItem(SESSION_KEY); + if (!raw) return null; + const saved = JSON.parse(raw) as { me?: string; baseline?: unknown; fired?: unknown }; + if (saved.me !== me) return null; + const baseline = reviveBaseline(saved.baseline); + if (!baseline) return null; + return { baseline, fired: Array.isArray(saved.fired) ? (saved.fired as string[]) : [] }; + } catch { + return null; + } +} + +function saveCheerSession(me: string, baseline: CheerBaseline, fired: Set) { + // never persist under an empty viewer: whoami resolves a tick after the + // first render, and a me='' save would clobber the real session so the + // hydrate that follows can't find it (and re-primes, replaying greetings). + if (!me) return; + try { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ me, baseline: serializeBaseline(baseline), fired: [...fired] }) + ); + } catch { + // sessionStorage can be unavailable (private mode, quota); losing the + // replay guard is a soft failure, not worth interrupting the board. + } +} /** Most toasts on screen at once; a fourth pushes the oldest out early. */ const MAX_VISIBLE = 3; /** How many past nudges the notification panel keeps — toasts are non-sticky @@ -136,11 +183,26 @@ export function useToasts( const timers = useRef>>(new Map()); const toastsRef = useRef([]); const firedKeys = useRef>(new Set()); + const hydrated = useRef(false); useEffect(() => { toastsRef.current = toasts; }, [toasts]); + // Rehydrate per-tab memory before any firing effect runs (this is declared + // above them, so on mount it runs first). A reload then resumes the diff + // against where the last tick left off, instead of re-priming from + // EMPTY_BASELINE and replaying every load-time greeting. + useEffect(() => { + if (hydrated.current || !me) return; + const saved = loadCheerSession(me); + if (saved) { + baseline.current = saved.baseline; + firedKeys.current = new Set(saved.fired); + } + hydrated.current = true; + }, [me]); + const remove = useCallback((id: number) => { const gone = toastsRef.current.find(t => t.id === id); gone?.onGone?.(); @@ -228,6 +290,7 @@ export function useToasts( baseline.current ); baseline.current = next; + saveCheerSession(me, next, firedKeys.current); // quick-wins is a batch, not one pull: swap its scroll-to-pull for the // filter action so clicking shows every small review, not just the first const bound = fresh.map(t => @@ -245,8 +308,11 @@ export function useToasts( useEffect(() => { const fresh = extras.filter(t => t.dedupeKey && !firedKeys.current.has(t.dedupeKey)); for (const t of fresh) firedKeys.current.add(t.dedupeKey as string); - if (fresh.length) push(fresh); - }, [extras, push]); + if (fresh.length) { + push(fresh); + saveCheerSession(me, baseline.current, firedKeys.current); + } + }, [extras, push, me]); // Dev-only preview handle: with the dummy backend the board is static, so // there are no live transitions to fire cheers off. Expose a way to conjure From 30d0ded06362acd4e90aa4273c730d63b3be4722 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 17:46:39 -0700 Subject: [PATCH 199/381] Fold aging and bot PRs into the review queue and the deal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are real review work the queue and "Deal me one" were ignoring. Aging (starved) PRs had a highlighted lane but were excluded from the reviewable pool the deal drew from, so a triage run never handed you the one thing most in danger of falling through. Bot PRs (the daily dependency bumps) sat only in a deprioritized fold — someone still has to move them along, but nothing surfaced them for review. - The deal now draws from the whole reviewable pool: primary-repo humans, the aging cross-repo backstop, and the reviewable bots. dealOne takes a `deprioritize` predicate; bots score below every human pull no matter how old, so they're only handed out once real work is clear, but still ranked among themselves. - The Review queue lane appends the reviewable bots at its tail — present and dealable, but last and capped away when the board is busy. - The bots fold now holds only what isn't up for review (merge-ready, in CI, or draft); the reviewable ones moved up. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/deal.test.ts | 31 ++++++++++++ frontend-v2/src/model/deal.ts | 9 +++- frontend-v2/src/views/Review.tsx | 80 +++++++++++++++++++++--------- 3 files changed, 95 insertions(+), 25 deletions(-) diff --git a/frontend-v2/src/model/deal.test.ts b/frontend-v2/src/model/deal.test.ts index e0bd4d75..c0333b7a 100644 --- a/frontend-v2/src/model/deal.test.ts +++ b/frontend-v2/src/model/deal.test.ts @@ -113,6 +113,37 @@ describe('dealOne — scoring bumps', () => { }); }); +describe('dealOne — deprioritize', () => { + it('hands out a demoted pull only once every non-demoted one is gone', () => { + // the bot is far older (would win on urgency) but must sink below a fresh + // human pull; passing the human then leaves only the bot to deal + const bot = dp({ + number: 1, + author: 'dependabot', + ageDays: 30, + starved: true, + starveScore: 900, + }); + const human = dp({ number: 2, author: 'alice', ageDays: 1 }); + const opts = baseOpts({ deprioritize: p => p.data.user.login === 'dependabot' }); + expect(dealOne([bot, human], opts)).toBe(human); + expect(dealOne([bot, human], { ...opts, passed: new Set(['org/repo#2']) })).toBe(bot); + }); + + it('still orders demoted pulls among themselves', () => { + const olderBot = dp({ + number: 1, + author: 'bot', + ageDays: 20, + starved: true, + starveScore: 800, + }); + const newerBot = dp({ number: 2, author: 'bot', ageDays: 2 }); + const opts = baseOpts({ deprioritize: () => true }); + expect(dealOne([olderBot, newerBot], opts)).toBe(olderBot); + }); +}); + describe('dealOne — determinism', () => { it('picks the same pull every time for the same inputs', () => { const queue = [dp({ number: 1 }), dp({ number: 2 }), dp({ number: 3 })]; diff --git a/frontend-v2/src/model/deal.ts b/frontend-v2/src/model/deal.ts index e2cee066..5445a0ed 100644 --- a/frontend-v2/src/model/deal.ts +++ b/frontend-v2/src/model/deal.ts @@ -21,6 +21,10 @@ export interface DealOptions { claims: Readonly>; /** session-only: keys the viewer has already passed on this sitting. */ passed: ReadonlySet; + /** pulls to hand out only once everything else is gone (bot PRs): they still + * need a reviewer, but shouldn't jump ahead of human work however old they + * get. Ranked among themselves by the same signals. */ + deprioritize?: (p: DerivedPull) => boolean; } /** Has `login` landed an active CR or QA stamp on this pull? */ @@ -49,7 +53,10 @@ function score(p: DerivedPull, opts: DealOptions): number { other => other.data.user.login === opts.me && hasStamp(other, author) ); const quickWin = p.sizeKnown && (p.weight === 'XS' || p.weight === 'S'); - return urgency + (familiar ? 2 : 0) + (owedByAuthor ? 2 : 0) + (quickWin ? 1 : 0); + const base = urgency + (familiar ? 2 : 0) + (owedByAuthor ? 2 : 0) + (quickWin ? 1 : 0); + // a demoted pull (a bot's) sinks below every non-demoted one no matter how + // old, but keeps its relative order among the other demoted ones + return opts.deprioritize?.(p) ? base - 1000 : base; } /** diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 2bbeae6d..83837934 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -115,14 +115,29 @@ function DealtCard({ * doesn't mean reopening; "Pass" skips to the next without claiming. Clicking * away or Escape ends the session. Opening always deals a fresh pull. */ -function DealButton({ queue, opts }: { queue: DerivedPull[]; opts: RowOptions }) { +function DealButton({ + queue, + opts, + deprioritize, +}: { + queue: DerivedPull[]; + opts: RowOptions; + /** bots (etc.) the pick should hand out only once human work is clear */ + deprioritize?: (p: DerivedPull) => boolean; +}) { const { pulls } = usePulldasher(); const me = opts.me; const [dealtKey, setDealtKey] = useState(null); const [passed, setPassed] = useState>(new Set()); const deal = (passedNow: ReadonlySet) => { - const picked = dealOne(queue, { me, pulls, claims: opts.claims ?? {}, passed: passedNow }); + const picked = dealOne(queue, { + me, + pulls, + claims: opts.claims ?? {}, + passed: passedNow, + deprioritize, + }); setDealtKey(picked ? pullKey(picked.data) : null); }; @@ -256,16 +271,42 @@ export function Review({ (p.status === 'needs_cr' || (p.status === 'needs_recr' && !p.recrBy.includes(me))) ); const aged = crPool.filter(p => p.starved).sort((a, b) => b.starveScore - a.starveScore); - // your review queue leads with your repos; everything else folds into "other - // repos" so it's reachable but not in the way. Starvation stays cross-repo - // (below) — the fairness backstop is deliberately everyone's job. const reviewable = crSort(crPool.filter(p => !p.starved)); - const queue = starFirst( + + // Bot PRs (dependency bumps, mostly) are review work too — someone has to + // move the daily ones along — just low priority. The reviewable ones join + // the queue tail and the deal, demoted so they're only handed out once human + // work is clear; the rest (already merge-ready, in CI, or draft) stay folded. + const bySecurityThenAge = (a: DerivedPull, b: DerivedPull) => + Number(b.data.labels.some(l => /security/i.test(l.title))) - + Number(a.data.labels.some(l => /security/i.test(l.title))) || b.ageDays - a.ageDays; + const botReviewable = bots + .filter( + p => + !p.crBy.includes(me) && + (p.status === 'needs_cr' || (p.status === 'needs_recr' && !p.recrBy.includes(me))) + ) + .sort(bySecurityThenAge); + const botKeys = new Set(botReviewable.map(p => pullKey(p.data))); + const botRest = bots.filter(p => !botKeys.has(pullKey(p.data))).sort(bySecurityThenAge); + const isDemoted = (p: DerivedPull) => botKeys.has(pullKey(p.data)); + + // your review queue leads with your repos, then the day's bot bumps at the + // tail; everything else folds into "other repos" so it's reachable but not in + // the way. Starvation stays cross-repo (the Aging lane) — the fairness + // backstop is deliberately everyone's job. + const queueHumans = starFirst( reviewable.filter(p => isPrimaryRepo(p.data.repo)), starred ); + const queue = [...queueHumans, ...botReviewable]; const queueOther = reviewable.filter(p => !isPrimaryRepo(p.data.repo)); + // Deal me one draws from the whole reviewable pool — your primary repos, the + // aging cross-repo backstop, and bots — so a triage run doesn't skip the + // stuff that quietly needs moving. Bots sink to the end of the pick. + const dealPool = [...queueHumans, ...aged, ...botReviewable]; + // Ready-to-merge is the author's button, not the reviewer's job: a count // in the rest group, not a lane at the top. const ready = others.filter(p => p.status === 'ready'); @@ -374,7 +415,7 @@ export function Review({ ciPending.length + ciRed.length + drafts.length + - bots.length + + botRest.length + closed.length; // bots/shipped stay reachable even when no human PRs need review @@ -445,7 +486,7 @@ export function Review({ pulls={queue} cap={9} opts={opts} - headerExtra={} + headerExtra={} /> - {/* deliberately never auto-opened, even on a quiet board — bots stay deprioritized */} + {/* the reviewable bots moved up into the queue and the deal; + what's left here isn't up for review (merge-ready, in CI, or + draft), so it stays folded and never auto-opens */} - {/* `security` is this org's most-used label (50 in 3 months), - almost all on bot bumps: they lead the fold */} - - Number(b.data.labels.some(l => /security/i.test(l.title))) - - Number(a.data.labels.some(l => /security/i.test(l.title))) || - b.ageDays - a.ageDays - )} - opts={opts} - id="review:bots" - /> + Date: Mon, 20 Jul 2026 18:00:34 -0700 Subject: [PATCH 200/381] Make "your turn" a claim prompt picked by best fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the turn rotation, which nudges someone to pick up a starved, unclaimed review. Who gets named was a bare hash of the pull key over the repo's reviewer pool — deterministic, but blind to who's actually a good fit. Since the pool is already everyone who CR's that repo, they're all repo-familiar; what separates them is reciprocity, the same signal "Deal me one" ranks by. turnFor now prefers a candidate the pull's author has reviewed before (a good turn owed back), and only falls back to the hash to break ties and spread the pick across pulls. It takes the board now, so app.tsx computes the assignments once into a map the rows look up instead of each Row re-scanning. The nudge itself was passive ("the rotation picked you"). It's now an actionable prompt with a "Claim it" button that claims the review in place (which also adds you as a GitHub reviewer), so acting on your turn doesn't mean hunting the PR down. Toasts gained an optional action button (actionLabel + onAction) for this. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 30 ++++++++++++-- frontend-v2/src/components/Legend.tsx | 18 +++++---- frontend-v2/src/components/Row.tsx | 13 +++---- frontend-v2/src/model/cheers.ts | 9 +++-- frontend-v2/src/model/rotation.test.ts | 35 ++++++++++++----- frontend-v2/src/model/rotation.ts | 54 +++++++++++++++++++------- frontend-v2/src/model/toast.ts | 6 +++ frontend-v2/src/notifications.ts | 2 +- frontend-v2/src/toasts.tsx | 45 +++++++++++++++------ 9 files changed, 154 insertions(+), 58 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 10e4bab7..89904e71 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -7,17 +7,17 @@ import { useState, type ReactNode, } from 'react'; -import { ago, closedEpoch, n, shortRepo } from './format'; +import { ago, closedEpoch, n, pullKey, shortRepo } from './format'; import type { ActionStateKey } from './model/actions'; import { actionState } from './model/actions'; import type { DerivedPull } from './model/status'; import { matchesWeightFilter } from './model/status'; import { buildParentLookup } from './model/stack'; -import { buildReviewerPools } from './model/rotation'; +import { buildReviewerPools, turnFor } from './model/rotation'; import { shipRelevance, shippedToast } from './model/shipped'; import type { Toast } from './model/toast'; import type { Team as TeamGroup } from './types'; -import { isSnoozed, markAllSeen, setWeightLabels, usePulldasher } from './store'; +import { claimReview, isSnoozed, markAllSeen, setWeightLabels, usePulldasher } from './store'; import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy'; import { loadSiteConfig, primeScope, useScope } from './prefs'; import { getSettings, useSettings } from './settings'; @@ -654,6 +654,17 @@ export function App() { // per-repo reviewer pool for the turn rotation (model/rotation.ts): built // once over the whole board's open pulls, same reasoning as parentOf above const pools = useMemo(() => buildReviewerPools(pulls), [pulls]); + // whose turn each starved, unclaimed pull is — computed once over the whole + // board (turnFor reads it for the reciprocity signal) so a Row just looks + // its answer up instead of re-scanning the board per row + const turns = useMemo(() => { + const m = new Map(); + for (const p of pulls) { + const who = turnFor(p, pools, pulls); + if (who) m.set(pullKey(p.data), who); + } + return m; + }, [pulls, pools]); // stable identity so memo(Row) can skip untouched rows on socket bursts const rowOpts: RowOptions = useMemo( () => ({ @@ -669,6 +680,7 @@ export function App() { parentOf, claims, pools, + turns, }), [ me, @@ -683,6 +695,7 @@ export function App() { parentOf, claims, pools, + turns, ] ); @@ -766,13 +779,22 @@ export function App() { setLens('review'); setWeightSel(['XS', 'S']); }, []); + // the "your turn" toast's Claim button: claim the review straight from the + // nudge (which also adds you as a GitHub reviewer, same as any claim) + const onClaimTurn = useCallback( + (repo: string, number: number) => { + const p = pulls.find(x => x.data.repo === repo && x.data.number === number); + if (p) claimReview(p.data); + }, + [pulls] + ); const { toasts, dismiss: dismissToast, history: toastHistory, clearHistory, dismissHistoryItem, - } = useToasts(pulls, me, claims, shippedExtras, closed, onQuickWins); + } = useToasts(pulls, me, claims, shippedExtras, closed, onQuickWins, onClaimTurn); return ( <> diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 8654d3ed..81d4eb30 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -248,7 +248,7 @@ export function Legend() { your turn} - def="a starved review nobody’s on gets pointed at one person so it doesn’t sit forever — you can still pass it to anyone" + def="a starved review nobody’s on gets pointed at the best-matched person so it doesn’t sit forever — a toast asks them to claim it, and anyone can still take it" />
@@ -260,14 +260,18 @@ export function Legend() {

When a PR has gone too long without enough CR and nobody has claimed it, the - board assigns it to one reviewer so it stops falling through the cracks. Every - client picks the same name with no coordination: it hashes the PR’s repo and - number onto one person from the pool who’ve CR’d that repo before, skipping the - author and anyone who already stamped this one. + board points it at one reviewer so it stops falling through the cracks. The + candidates are everyone who’s CR’d that repo before (minus the author and + anyone who already stamped this one), so they all know the code; the pick then + favors whoever the author has reviewed before — a good turn owed back, the same + signal “Deal me one” leans on.

- It’s a nudge, not a lock — anyone can take it, and an explicit GitHub review - request overrides the guess entirely. + If you’re the pick, a toast asks you to claim it right there (which also adds + you as a GitHub reviewer). Every client lands on the same name with no + coordination, and ties spread across PRs so it isn’t always one person. It’s a + nudge, not a lock — anyone can take it, and an explicit GitHub review request + overrides the guess entirely.

diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index d96650d9..79645724 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -2,7 +2,6 @@ import { memo, useState, type ReactNode } from 'react'; import type { DerivedPull } from '../model/status'; import { isIterating, lastPushEpoch } from '../model/status'; import { rowNote } from '../model/actions'; -import { turnFor } from '../model/rotation'; import { requestedReviewers, reviewRequestedFrom } from '../model/reviewers'; import { matchedRegions } from '../model/regions'; import type { ParentRef } from '../model/stack'; @@ -61,6 +60,9 @@ export interface RowOptions { /** per-repo reviewer pool for the deterministic turn rotation * (model/rotation.ts), memoized once in app.tsx over the whole board. */ pools?: ReadonlyMap; + /** pull key → whose turn it is (the best-fit reviewer for a starved, + * unclaimed PR), computed once in app.tsx so the row just looks it up. */ + turns?: ReadonlyMap; } /** @@ -102,10 +104,6 @@ function consumeDealtFlash(key: string): boolean { return true; } -// stable reference for lenses that haven't threaded RowOptions.pools yet, so -// turnFor's .get() always has a Map to call -const EMPTY_POOLS = new Map(); - interface Flag { key: string; /** 'warn' = act on it (amber); 'note' = a neutral fact (muted) */ @@ -696,7 +694,7 @@ function RowImpl({ // both optional, so a lens that hasn't threaded them (yet) just sees null // and rowNote falls back to its base (pre-coordination) note const claim = claimFor(d, opts.claims ?? {}); - const turn = turnFor(pull, opts.pools ?? EMPTY_POOLS); + const turn = opts.turns?.get(key) ?? null; const poolSize = opts.pools?.get(d.repo)?.length ?? 0; // the two-slot action/context note, the same in every lens (model/actions.ts) // — never both-null for an open pull, so the context text always renders @@ -836,5 +834,6 @@ export const Row = memo( a.opts.onWeightToggle === b.opts.onWeightToggle && a.opts.parentOf === b.opts.parentOf && a.opts.claims === b.opts.claims && - a.opts.pools === b.opts.pools + a.opts.pools === b.opts.pools && + a.opts.turns === b.opts.turns ); diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index c3d6fa12..a853d743 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -319,7 +319,7 @@ export function readSignals(input: CheerInput): Signals { if (st === 'review') review++; else if (st === 'qa') qa++; else if (st === 'restamp') restampKeys.add(key); - if (!mine && !claims[key] && turnFor(p, pools) === me) turns.set(key, p); + if (!mine && !claims[key] && turnFor(p, pools, pulls) === me) turns.set(key, p); } const reviewableUnclaimed = pulls.filter( @@ -562,7 +562,7 @@ export const CHEER_CATALOG: { kind: 'your-turn', group: 'nudge', label: 'Your turn', - hint: 'The rotation named you on a starved review.', + hint: 'You’re the best-matched reviewer for a starved, unclaimed PR.', }, { kind: 're-stamp-owed', @@ -729,9 +729,10 @@ export function diffCheers( push('your-turn', { tone: 'nag', icon: '⏳', - title: 'Your turn on this', - body: `Waiting ${Math.max(1, Math.round(p.ageDays))}d — the rotation picked you.`, + title: 'Your turn to review', + body: `Waiting ${Math.max(1, Math.round(p.ageDays))}d with nobody on it — you're the best fit. Claim it?`, pull: pullRef(p), + actionLabel: 'Claim it', dedupeKey: `turn:${key}`, }); } diff --git a/frontend-v2/src/model/rotation.test.ts b/frontend-v2/src/model/rotation.test.ts index e8fe10be..fcf06dbc 100644 --- a/frontend-v2/src/model/rotation.test.ts +++ b/frontend-v2/src/model/rotation.test.ts @@ -10,6 +10,7 @@ function dp(o: { status?: Status; starved?: boolean; crBy?: string[]; + qaBy?: string[]; crLogins?: string[]; }): DerivedPull { return { @@ -24,6 +25,7 @@ function dp(o: { status: o.status ?? 'needs_cr', starved: o.starved ?? true, crBy: o.crBy ?? [], + qaBy: o.qaBy ?? [], } as unknown as DerivedPull; } @@ -55,41 +57,56 @@ describe('turnFor', () => { ]); it('is null when the pull is not starved', () => { - expect(turnFor(dp({ starved: false }), pools)).toBeNull(); + expect(turnFor(dp({ starved: false }), pools, [])).toBeNull(); }); it('is null outside needs_cr/needs_recr', () => { - expect(turnFor(dp({ status: 'ready' }), pools)).toBeNull(); - expect(turnFor(dp({ status: 'dev_block' }), pools)).toBeNull(); + expect(turnFor(dp({ status: 'ready' }), pools, [])).toBeNull(); + expect(turnFor(dp({ status: 'dev_block' }), pools, [])).toBeNull(); }); it('is null when the repo has no pool', () => { - expect(turnFor(dp({ repo: 'org/unknown' }), pools)).toBeNull(); + expect(turnFor(dp({ repo: 'org/unknown' }), pools, [])).toBeNull(); }); it('is null when every pool member is the author or already stamped', () => { const p = dp({ author: 'alice', crBy: ['bob', 'carol'] }); - expect(turnFor(p, pools)).toBeNull(); + expect(turnFor(p, pools, [])).toBeNull(); }); it('picks the same name on every call — deterministic', () => { const p = dp({ number: 42 }); - const first = turnFor(p, pools); + const first = turnFor(p, pools, []); expect(first).not.toBeNull(); - for (let i = 0; i < 5; i++) expect(turnFor(p, pools)).toBe(first); + for (let i = 0; i < 5; i++) expect(turnFor(p, pools, [])).toBe(first); }); it('picks a candidate from the eligible pool, excluding the author and existing CR', () => { const p = dp({ author: 'bob', crBy: ['carol'] }); - expect(turnFor(p, pools)).toBe('alice'); + expect(turnFor(p, pools, [])).toBe('alice'); }); it('different pull numbers can land on different names', () => { const picks = new Set(); - for (let n = 0; n < 30; n++) picks.add(turnFor(dp({ number: n }), pools)); + // no reciprocity signal (empty board) → falls through to the hash spread + for (let n = 0; n < 30; n++) picks.add(turnFor(dp({ number: n }), pools, [])); // over 30 distinct keys hashed into a 3-person pool, expect more than // one name to come up — a constant pick would mean the hash isn't // actually varying with the pull identity expect(picks.size).toBeGreaterThan(1); }); + + it('prefers a candidate the author has reviewed before (reciprocity)', () => { + // pull authored by dave; dave has CR'd one of carol's PRs elsewhere, so + // carol is owed a look back and leads the pool regardless of the hash + const target = dp({ number: 7, author: 'dave' }); + const board = [target, dp({ author: 'carol', number: 99, crBy: ['dave'] })]; + expect(turnFor(target, pools, board)).toBe('carol'); + }); + + it('counts a QA stamp as reciprocity too', () => { + const target = dp({ number: 8, author: 'dave' }); + const board = [target, dp({ author: 'bob', number: 98, qaBy: ['dave'] })]; + expect(turnFor(target, pools, board)).toBe('bob'); + }); }); diff --git a/frontend-v2/src/model/rotation.ts b/frontend-v2/src/model/rotation.ts index 15f3329f..4ce56046 100644 --- a/frontend-v2/src/model/rotation.ts +++ b/frontend-v2/src/model/rotation.ts @@ -5,10 +5,16 @@ import { isBotLogin } from './visibility'; /** * Turn rotation: a starved, CR-incomplete pull with no claim still needs * someone to pick it up. Rather than a server-assigned queue, every client - * computes the same "whose turn" answer independently — a pure hash of the - * pull's key over its repo's reviewer pool. Same inputs (the board's CR - * signatures, the pull's repo/number) → same name on every client, with no - * round trip and nothing to keep in sync. + * computes the same "whose turn" answer independently, so there's no round + * trip and nothing to keep in sync. + * + * Who gets named is the best-fit reviewer, not just a hash: the pick leans on + * the same reciprocity signal deal.ts ranks pulls by — someone the pull's + * author has reviewed before is owed a look back, so they lead. (Repo + * familiarity can't separate candidates here: the pool IS everyone who CR's + * this repo, so they're all familiar.) When nobody's specifically owed, or + * several are, a deterministic djb2 hash of the pull's key spreads the pick + * across pulls so it isn't always the same name. */ /** djb2: a tiny, deterministic string hash — good enough for picking an @@ -47,17 +53,20 @@ const TURN_STATUSES: Status[] = ['needs_cr', 'needs_recr']; /** * Whose turn it is on this pull, or null when there's no rotation to name: - * not starved, not CR-incomplete, or an empty pool once the author and - * anyone already carrying an active CR stamp are excluded. Deterministic — - * hash(`${repo}#${number}`) modulo the (already-excluded) pool picks the - * same login on every client for the same pull, every time, with no state to - * agree on. + * not starved, not CR-incomplete, or an empty pool once the author and anyone + * already carrying an active CR stamp are excluded. Deterministic — the same + * board picks the same login on every client, with no state to agree on. * - * A claim on the pull supersedes this at the CALLER's level (Row, rowNote's - * withCoordination, notifications) — turnFor itself knows nothing about - * claims. + * `pulls` is the whole board, read for the reciprocity signal (who the author + * has reviewed before). A claim on the pull supersedes this at the CALLER's + * level (Row, rowNote's withCoordination, notifications) — turnFor itself + * knows nothing about claims. */ -export function turnFor(p: DerivedPull, pools: ReadonlyMap): string | null { +export function turnFor( + p: DerivedPull, + pools: ReadonlyMap, + pulls: readonly DerivedPull[] +): string | null { if (!p.starved || !TURN_STATUSES.includes(p.status)) return null; // An explicit GitHub review request answers "whose turn" authoritatively — // don't also rotate a name onto the pull, or the board would tell someone @@ -68,6 +77,21 @@ export function turnFor(p: DerivedPull, pools: ReadonlyMap): s const author = p.data.user.login; const candidates = pool.filter(login => login !== author && !p.crBy.includes(login)); if (!candidates.length) return null; - const idx = djb2(`${p.data.repo}#${p.data.number}`) % candidates.length; - return candidates[idx]; + + // Reciprocity: tag every candidate the author has already reviewed (an + // active CR or QA stamp on a PR that candidate authored). One board pass, + // not one per candidate. Those are owed a look back, so they form the + // preferred tier; if none qualify, everyone's fair game. + const candSet = new Set(candidates); + const owed = new Set(); + for (const o of pulls) { + const a = o.data.user.login; + if (candSet.has(a) && (o.crBy.includes(author) || o.qaBy.includes(author))) owed.add(a); + } + const tier = candidates.filter(c => owed.has(c)); + const pick = tier.length ? tier : candidates; + + // deterministic spread across pulls so equally-good candidates share the load + const idx = djb2(`${p.data.repo}#${p.data.number}`) % pick.length; + return pick[idx]; } diff --git a/frontend-v2/src/model/toast.ts b/frontend-v2/src/model/toast.ts index 89878926..d67d59b6 100644 --- a/frontend-v2/src/model/toast.ts +++ b/frontend-v2/src/model/toast.ts @@ -25,6 +25,12 @@ export interface Toast { ttlMs?: number; /** custom click action; when set, it replaces the default scroll-to-pull */ onAct?: () => void; + /** an explicit action button in the card (e.g. "Claim it" on a your-turn + * nudge). The label is declarative; the caller attaches onAction, since it's + * impure. Distinct from onAct (the whole-card click), so the card can still + * reveal the pull while the button does something committal. */ + actionLabel?: string; + onAction?: () => void; /** run once when the toast leaves for any reason (dismiss/timeout/evicted) */ onGone?: () => void; } diff --git a/frontend-v2/src/notifications.ts b/frontend-v2/src/notifications.ts index 4cd1059f..a937cd0a 100644 --- a/frontend-v2/src/notifications.ts +++ b/frontend-v2/src/notifications.ts @@ -188,7 +188,7 @@ export function useNotifications( continue; } const key = pullKey(p.data); - const turn = turnFor(p, pools); + const turn = turnFor(p, pools, pulls); if (turn === me && !claims[key]) current.set(key, TURN_ACTION); } diff --git a/frontend-v2/src/toasts.tsx b/frontend-v2/src/toasts.tsx index aa82e9f3..c717b8e6 100644 --- a/frontend-v2/src/toasts.tsx +++ b/frontend-v2/src/toasts.tsx @@ -145,9 +145,11 @@ const SAMPLE_CHEERS: CheerToast[] = [ { tone: 'nag', icon: '⏳', - title: 'Your turn on this', - body: 'Waiting 6d — the rotation picked you.', + title: 'Your turn to review', + body: "Waiting 6d with nobody on it — you're the best fit. Claim it?", pull: { repo: 'org/repo', number: 5, title: 'Discourage new files in Exec/ dir' }, + actionLabel: 'Claim it', + onAction: () => {}, }, ]; @@ -173,7 +175,10 @@ export function useToasts( closed: PullData[] = [], /** clicking the quick-wins toast filters the board to the small ones rather * than scrolling to a single pull — a batch nudge wants a batch view */ - onQuickWins?: () => void + onQuickWins?: () => void, + /** the your-turn toast's "Claim it" button claims that review (which also + * adds you as a GitHub reviewer) — bound here since claiming is impure */ + onClaimTurn?: (repo: string, number: number) => void ) { const [toasts, setToasts] = useState([]); const [history, setHistory] = useState([]); @@ -291,15 +296,20 @@ export function useToasts( ); baseline.current = next; saveCheerSession(me, next, firedKeys.current); - // quick-wins is a batch, not one pull: swap its scroll-to-pull for the - // filter action so clicking shows every small review, not just the first - const bound = fresh.map(t => - t.dedupeKey?.startsWith('quick:') && onQuickWins - ? { ...t, pull: undefined, onAct: onQuickWins } - : t - ); + // bind the impure actions the pure evaluator can't: quick-wins filters + // the board to the small ones (a batch nudge wants a batch view), and the + // your-turn nudge's "Claim it" button claims that review in place. + const bound = fresh.map(t => { + if (t.dedupeKey?.startsWith('quick:') && onQuickWins) + return { ...t, pull: undefined, onAct: onQuickWins }; + if (t.dedupeKey?.startsWith('turn:') && onClaimTurn && t.pull) { + const { repo, number } = t.pull; + return { ...t, onAction: () => onClaimTurn(repo, number) }; + } + return t; + }); if (on) push(bound); - }, [pulls, closed, me, claims, push, onQuickWins]); + }, [pulls, closed, me, claims, push, onQuickWins, onClaimTurn]); // pre-built one-shot toasts from the caller (e.g. the shipped catch-up), // deduped by dedupeKey so the same logical toast never re-fires on a later @@ -443,6 +453,19 @@ function ToastCard({ toast, onDismiss }: { toast: LiveToast; onDismiss: (id: num {toast.body && ( {toast.body} )} + {toast.actionLabel && toast.onAction && ( + + )} + {toast.shimmer && ( + + )}
); } From bcd93dc89a3dcb333aa7c88f4ec49b8d2baa2d66 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 20:39:49 -0700 Subject: [PATCH 202/381] Drop reward confetti and stop phantom load-time notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two toast fixes shipping together. Confetti out: the emoji-confetti burst read as junk on a real board, so it's gone entirely — module, Toast.confettiEmoji, and the mount-time canvas fire. The bigger wins (inbox zero, board's clear, a PR going green) now lean on the sparkle burst + shimmer they already carried; pr-green gains the sparkle it was missing. Phantom notifications on refresh: on reload the persisted cheer baseline hydrates with the previous session's real counts, but the store publishes an empty snapshot first while the initialize payload is still in flight. Diffing that empty board against a primed baseline fired "Board's clear" and "Inbox zero", and once the real data landed every stamp looked newly landed so "reviews this sitting" jumped. Gate on the store's existing `initialized` flag ("[] means empty, not loading"): evaluateCheers no-ops and carries the baseline until the first payload arrives, and useNotifications skips recording an empty baseline the same way. Two model tests lock it in — one asserts the no-op while loading, one documents the phantom clears the guard prevents. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 4 +- frontend-v2/src/confetti.ts | 131 --------------------------- frontend-v2/src/model/cheers.test.ts | 31 +++++++ frontend-v2/src/model/cheers.ts | 13 ++- frontend-v2/src/model/toast.ts | 4 - frontend-v2/src/notifications.ts | 11 ++- frontend-v2/src/toasts.tsx | 37 ++++---- 7 files changed, 70 insertions(+), 161 deletions(-) delete mode 100644 frontend-v2/src/confetti.ts diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 89904e71..60de21f9 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -228,7 +228,7 @@ export function App() { claims, } = usePulldasher(); // desktop notifications watch the whole board, not the current filter - useNotifications(pulls, me, claims); + useNotifications(pulls, me, claims, initialized); const [scope, setScope] = useScope(); // a v1 bookmark (?repo=…&author=…&cryo=1…) opens Classic configured the // same way; the chip below shows what it applied and dismisses it @@ -794,7 +794,7 @@ export function App() { history: toastHistory, clearHistory, dismissHistoryItem, - } = useToasts(pulls, me, claims, shippedExtras, closed, onQuickWins, onClaimTurn); + } = useToasts(pulls, me, claims, shippedExtras, closed, onQuickWins, onClaimTurn, initialized); return ( <> diff --git a/frontend-v2/src/confetti.ts b/frontend-v2/src/confetti.ts deleted file mode 100644 index 8bb4ddbf..00000000 --- a/frontend-v2/src/confetti.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * A tiny, self-contained emoji-confetti burst — the reward flourish for the - * bigger cheers (inbox zero, board's clear, a PR going green). Hand-rolled - * rather than pulling in a particle library: one shared full-screen canvas - * overlay, a pooled particle array, and a single rAF loop that stops itself - * when everything's settled. Bursts compose — two rewards at once just add - * particles to the same pool. - * - * Skips entirely under prefers-reduced-motion; the toast still says what - * happened, it just doesn't throw paper. - */ - -interface Particle { - x: number; - y: number; - vx: number; - vy: number; - life: number; - rot: number; - vr: number; - /** an emoji glyph, or null for a colored confetti rectangle */ - emoji: string | null; - color: string; - size: number; -} - -// the app's brand blue leads, then a warm confetti spread -const COLORS = ['#0071CE', '#f2b544', '#2fb9a6', '#e35aa6', '#ff5c6a', '#7ee0a0']; -const GRAVITY = 0.22; - -let canvas: HTMLCanvasElement | null = null; -let ctx: CanvasRenderingContext2D | null = null; -let particles: Particle[] = []; -let running = false; - -function prefersReducedMotion(): boolean { - return ( - typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches - ); -} - -function ensureCanvas() { - if (canvas) return; - canvas = document.createElement('canvas'); - // above the board and the toast stack (z-50), below the modal scrim (z-100) - canvas.style.cssText = - 'position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:55'; - canvas.setAttribute('aria-hidden', 'true'); - document.body.appendChild(canvas); - ctx = canvas.getContext('2d'); - sizeToWindow(); - window.addEventListener('resize', sizeToWindow); -} - -function sizeToWindow() { - if (!canvas || !ctx) return; - const dpr = window.devicePixelRatio || 1; - canvas.width = Math.floor(window.innerWidth * dpr); - canvas.height = Math.floor(window.innerHeight * dpr); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); -} - -/** Throw a burst of the given emoji (mixed with colored confetti) up and out - * from a page point — pass the medallion's center. No-op under reduced motion. */ -export function burstEmoji(x: number, y: number, emojis: string[]) { - if (prefersReducedMotion()) return; - ensureCanvas(); - const count = 28; - for (let i = 0; i < count; i++) { - // fan upward: straight up ±~57°, with a little extra lift - const angle = -Math.PI / 2 + (Math.random() - 0.5) * 2; - const speed = 5 + Math.random() * 7; - const asEmoji = emojis.length > 0 && Math.random() < 0.5; - particles.push({ - x, - y, - vx: Math.cos(angle) * speed, - vy: Math.sin(angle) * speed - 2, - life: 1, - rot: Math.random() * Math.PI * 2, - vr: (Math.random() - 0.5) * 0.35, - emoji: asEmoji ? emojis[Math.floor(Math.random() * emojis.length)] : null, - color: COLORS[Math.floor(Math.random() * COLORS.length)], - size: asEmoji ? 16 + Math.random() * 8 : 6 + Math.random() * 4, - }); - } - if (!running) { - running = true; - requestAnimationFrame(tick); - } -} - -function tick() { - if (!ctx || !canvas) { - running = false; - return; - } - const w = window.innerWidth; - const h = window.innerHeight; - ctx.clearRect(0, 0, w, h); - for (const p of particles) { - p.vy += GRAVITY; - p.vx *= 0.99; - p.x += p.vx; - p.y += p.vy; - p.rot += p.vr; - p.life -= 0.01; - ctx.save(); - ctx.globalAlpha = Math.max(0, Math.min(1, p.life)); - ctx.translate(p.x, p.y); - ctx.rotate(p.rot); - if (p.emoji) { - ctx.font = `${p.size}px serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(p.emoji, 0, 0); - } else { - ctx.fillStyle = p.color; - ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6); - } - ctx.restore(); - } - // drop the dead and the fallen off the bottom - particles = particles.filter(p => p.life > 0 && p.y < h + 40); - if (particles.length) { - requestAnimationFrame(tick); - } else { - ctx.clearRect(0, 0, w, h); - running = false; - } -} diff --git a/frontend-v2/src/model/cheers.test.ts b/frontend-v2/src/model/cheers.test.ts index 3414dfb0..2e23e30d 100644 --- a/frontend-v2/src/model/cheers.test.ts +++ b/frontend-v2/src/model/cheers.test.ts @@ -6,6 +6,7 @@ import { type CheerBaseline, diffCheers, EMPTY_BASELINE, + evaluateCheers, PRIORITY_ORDER, reviveBaseline, serializeBaseline, @@ -608,6 +609,36 @@ describe('baseline persistence', () => { }); }); +describe('evaluateCheers — loading guard', () => { + // a baseline primed from a prior session: a real queue and board behind it + const primedBase: CheerBaseline = { + ...EMPTY_BASELINE, + primed: true, + queue: 8, + boardQueue: 15, + }; + + it('no-ops and carries the baseline while the board is still loading', () => { + // the empty snapshot the store publishes before the first payload lands + const { toasts, next } = evaluateCheers( + { pulls: [], me: 'me', claims: {}, ready: false }, + primedBase + ); + expect(toasts).toEqual([]); + expect(next).toBe(primedBase); + }); + + it('WOULD fire phantom clears against that empty board once ready — the bug the guard prevents', () => { + const { toasts } = evaluateCheers( + { pulls: [], me: 'me', claims: {}, ready: true }, + primedBase + ); + const keys = toasts.map(t => t.dedupeKey); + expect(keys).toContain('inbox:zero'); + expect(keys).toContain('board:clear'); + }); +}); + describe('cheer catalog', () => { it('documents exactly every toast kind, once each', () => { const catalogKinds = CHEER_CATALOG.map(c => c.kind); diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index 73e22cd1..ea438504 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -170,6 +170,12 @@ export interface CheerInput { * cap, so muting a high-priority kind frees its slot for a shown one rather * than firing then hiding it. */ muted?: ReadonlySet; + /** the board's first payload has arrived. `false` means it's still loading + * (an empty snapshot), and diffing that against a primed baseline would fire + * a phantom "Board's clear" / "Inbox zero" and count every stamp as newly + * landed — so evaluateCheers no-ops and carries the baseline until it's true. + * Defaults to true so tests and older callers evaluate as before. */ + ready?: boolean; } /** A claim you're sitting on stops absolving other reviewers at 2h (see @@ -448,6 +454,9 @@ export function evaluateCheers( input: CheerInput, base: CheerBaseline ): { toasts: CheerToast[]; next: CheerBaseline } { + // still loading — fire nothing and carry the baseline forward, so a primed + // baseline never diffs against an empty board (the phantom-clear bug) + if (input.ready === false) return { toasts: [], next: base }; return diffCheers(readSignals(input), input.me, base, input.muted); } @@ -718,7 +727,6 @@ export function diffCheers( title: 'Inbox zero', body: "Nothing's waiting on you.", celebrate: true, - confettiEmoji: '🎉', shimmer: true, dedupeKey: 'inbox:zero', }); @@ -879,7 +887,7 @@ export function diffCheers( title: 'Green — ship it', body: 'CR + QA both cleared.', pull: pullRef(p), - confettiEmoji: '🚀', + celebrate: true, shimmer: true, dedupeKey: `green:${key}`, }); @@ -945,7 +953,6 @@ export function diffCheers( title: "Board's clear", body: 'Nothing waiting on anyone.', celebrate: true, - confettiEmoji: '🎊', shimmer: true, dedupeKey: 'board:clear', }); diff --git a/frontend-v2/src/model/toast.ts b/frontend-v2/src/model/toast.ts index eef4ce1e..590ea1c9 100644 --- a/frontend-v2/src/model/toast.ts +++ b/frontend-v2/src/model/toast.ts @@ -16,10 +16,6 @@ export interface Toast { /** hero rewards (queue cleared, a milestone) get a sparkle burst off the * medallion */ celebrate?: boolean; - /** fire an emoji-confetti burst from the medallion when this toast lands, - * using this glyph (plus colored confetti). Reserved for the bigger wins; - * never the everyday "review landed". */ - confettiEmoji?: string; /** sweep a one-shot light glint across the card — a premium sheen for a * reward, not the nags */ shimmer?: boolean; diff --git a/frontend-v2/src/notifications.ts b/frontend-v2/src/notifications.ts index a937cd0a..4e477484 100644 --- a/frontend-v2/src/notifications.ts +++ b/frontend-v2/src/notifications.ts @@ -145,7 +145,11 @@ const MAX_PER_TICK = 5; export function useNotifications( pulls: DerivedPull[], me: string, - claims: Readonly> = {} + claims: Readonly> = {}, + /** the board's first payload has arrived. While false the board is still + * loading (an empty snapshot); recording that as the baseline would make + * every real pull look "new" and fire a backlog of alerts once data lands. */ + ready = true ) { // pull key → the action last seen for it, so a changed action re-alerts const seen = useRef>(new Map()); @@ -168,6 +172,9 @@ export function useNotifications( }, []); useEffect(() => { + // the board is still loading — don't record an empty snapshot as the + // baseline, or every real pull looks "new" once data lands + if (!ready) return; const s = getSettings(); const active = notificationsSupported && s.notify && Notification.permission === 'granted' && !!me; @@ -211,5 +218,5 @@ export function useNotifications( } if (fired > 0 && s.notifySound) chime(); seen.current = current; - }, [pulls, me, claims]); + }, [ready, pulls, me, claims]); } diff --git a/frontend-v2/src/toasts.tsx b/frontend-v2/src/toasts.tsx index 98012259..40b3876f 100644 --- a/frontend-v2/src/toasts.tsx +++ b/frontend-v2/src/toasts.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { burstEmoji } from './confetti'; import { githubUrl, rowDomId } from './format'; import { type CheerBaseline, @@ -131,7 +130,6 @@ const SAMPLE_CHEERS: CheerToast[] = [ title: 'Inbox zero', body: "Nothing's waiting on you.", celebrate: true, - confettiEmoji: '🎉', shimmer: true, }, { @@ -140,7 +138,7 @@ const SAMPLE_CHEERS: CheerToast[] = [ title: 'Green — ship it', body: 'CR + QA both cleared.', pull: { repo: 'org/repo', number: 77, title: 'Migrate the cart to the new checkout' }, - confettiEmoji: '🚀', + celebrate: true, shimmer: true, }, { @@ -163,7 +161,6 @@ const SAMPLE_CHEERS: CheerToast[] = [ title: "Board's clear", body: 'Nothing waiting on anyone.', celebrate: true, - confettiEmoji: '🎊', shimmer: true, }, { @@ -202,7 +199,12 @@ export function useToasts( onQuickWins?: () => void, /** the your-turn toast's "Claim it" button claims that review (which also * adds you as a GitHub reviewer) — bound here since claiming is impure */ - onClaimTurn?: (repo: string, number: number) => void + onClaimTurn?: (repo: string, number: number) => void, + /** the board's first data has arrived. While false the board is still + * loading (an empty snapshot), and diffing that against a primed baseline + * would fire phantom "Board's clear" / "Inbox zero" and count every stamp + * as newly landed — so hold every evaluation until it's true. */ + ready = true ) { const [toasts, setToasts] = useState([]); const [history, setHistory] = useState([]); @@ -306,6 +308,10 @@ export function useToasts( useEffect(() => { const on = getSettings().cheers; + // `ready` gates the evaluation inside the model: while the board is still + // loading (an empty snapshot) it no-ops and carries the baseline, so a + // primed baseline never fires phantom cleared/inbox-zero or miscounts + // stamps against empty data. const { toasts: fresh, next } = evaluateCheers( { pulls, @@ -315,11 +321,14 @@ export function useToasts( now: Date.now(), claimWarnMs: getSettings().claimWarnMins * 60_000, muted: new Set(getSettings().mutedCheers as ToastKind[]), + ready, }, baseline.current ); baseline.current = next; - saveCheerSession(me, next, firedKeys.current); + // only persist once loaded, so a load-time tick can't overwrite the saved + // session with a not-yet-hydrated baseline + if (ready) saveCheerSession(me, next, firedKeys.current); // bind the impure actions the pure evaluator can't: quick-wins filters // the board to the small ones (a batch nudge wants a batch view), and the // your-turn nudge's "Claim it" button claims that review in place. @@ -333,20 +342,21 @@ export function useToasts( return t; }); if (on) push(bound); - }, [pulls, closed, me, claims, push, onQuickWins, onClaimTurn]); + }, [ready, pulls, closed, me, claims, push, onQuickWins, onClaimTurn]); // pre-built one-shot toasts from the caller (e.g. the shipped catch-up), // deduped by dedupeKey so the same logical toast never re-fires on a later // tick — not gated by the cheers setting, since this is informational, not // gamification. useEffect(() => { + if (!ready) return; const fresh = extras.filter(t => t.dedupeKey && !firedKeys.current.has(t.dedupeKey)); for (const t of fresh) firedKeys.current.add(t.dedupeKey as string); if (fresh.length) { push(fresh); saveCheerSession(me, baseline.current, firedKeys.current); } - }, [extras, push, me]); + }, [ready, extras, push, me]); // Dev-only preview handle: with the dummy backend the board is static, so // there are no live transitions to fire cheers off. Expose a way to conjure @@ -468,16 +478,6 @@ function ToastCard({ toast, onDismiss }: { toast: LiveToast; onDismiss: (id: num onDismiss(toast.id); }; - // fire the emoji-confetti burst once, from the medallion's spot on screen, - // as the card lands. Deps are the burst identity, which never changes for a - // given card (keyed by id), so this runs exactly on mount. - const medallionRef = useRef(null); - useEffect(() => { - if (!toast.confettiEmoji) return; - const r = medallionRef.current?.getBoundingClientRect(); - if (r) burstEmoji(r.left + r.width / 2, r.top + r.height / 2, [toast.confettiEmoji]); - }, [toast.confettiEmoji]); - const tone = reward ? 'border-brand-100 bg-surface ring-1 ring-brand/15' : info @@ -510,7 +510,6 @@ function ToastCard({ toast, onDismiss }: { toast: LiveToast; onDismiss: (id: num : {})} > {toast.celebrate && } From 86cc51fcde8ce4c56a6d56e2e3c3553cbf583f6d Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 20:55:56 -0700 Subject: [PATCH 203/381] Fix Build check: exclude nested dist/ dirs from eslint The root "eslint ./" lint step was linting frontend-v2's built, minified bundle (frontend-v2/dist/assets/*.js) and the legacy frontend/dist output, producing hundreds of no-prototype-builtins, no-empty, no-undef, etc. errors from code we don't own. The ignorePatterns entry "dist/*" only anchors to a top-level dist/ (like .gitignore, a pattern containing a non-trailing slash is rooted at the config file's directory), so it never matched the nested frontend-v2/dist/ or frontend/dist/ that "npm run build" produces before lint runs in CI. Switch it to "**/dist/**" so it matches at any depth. With the build output out of the way, eslint surfaced a handful of real errors in frontend-v2/src that were previously drowned out: - socket.ts: a claims map declared with `let` but never reassigned (prefer-const), and an empty `() => {}` unsubscribe stub (no-empty-function); switched to `() => undefined` which the ConnectionState unsubscribe type still accepts. - toasts.tsx: same empty-arrow-function stub on a sample toast's onAction. - StatePopover.tsx: an external link used rel="noopener" without rel="noreferrer" (react/jsx-no-target-blank). - vite.config.ts: `@tailwindcss/vite` ships only an "exports" map (no "main"/"module" fallback), which the installed eslint-import-resolver-node@0.3.6 can't follow regardless of version bumped locally (confirmed 0.4.0 has the same gap) - tsc and vite both resolve it fine, so this is a resolver limitation rather than a real unresolved import. Suppressed with a scoped eslint-disable-next-line and a comment explaining why. Verified locally with the same Node 24 CI uses: npm install (which triggers postinstall's frontend-v2 build), npm run lint (0 errors), npm run build, plus frontend-v2's own `npx tsc --noEmit` and `npm test` (298 tests, all passing). Co-Authored-By: Claude Opus 4.8 --- .eslintrc.json | 2 +- frontend-v2/src/backend/socket.ts | 4 ++-- frontend-v2/src/components/StatePopover.tsx | 2 +- frontend-v2/src/toasts.tsx | 2 +- frontend-v2/vite.config.ts | 3 +++ 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 043c72ee..9625fb75 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -12,7 +12,7 @@ "globals": { "App": true }, - "ignorePatterns": ["dist/*"], + "ignorePatterns": ["**/dist/**"], "overrides": [ { "files": ["**/*.ts", "**/*.tsx"], diff --git a/frontend-v2/src/backend/socket.ts b/frontend-v2/src/backend/socket.ts index 3afcff51..921292af 100644 --- a/frontend-v2/src/backend/socket.ts +++ b/frontend-v2/src/backend/socket.ts @@ -105,7 +105,7 @@ function dummyBackend(): Backend { // The dummy stand-in for the server's claims map: mutated locally by // claimReview/releaseReview and re-emitted, same shape a real socket // 'reviewClaims' broadcast would carry. - let claims: ReviewClaims = {}; + const claims: ReviewClaims = {}; let onClaims: ((claims: ReviewClaims) => void) | null = null; const publishClaims = () => onClaims?.({ ...claims }); @@ -140,7 +140,7 @@ function dummyBackend(): Backend { }, onConnection(handler) { handler('connected'); - return () => {}; + return () => undefined; }, refreshPull(repo, number) { // The dummy data is static, but the refresh-progress UI still needs diff --git a/frontend-v2/src/components/StatePopover.tsx b/frontend-v2/src/components/StatePopover.tsx index f0ca4f58..0c6580a5 100644 --- a/frontend-v2/src/components/StatePopover.tsx +++ b/frontend-v2/src/components/StatePopover.tsx @@ -185,7 +185,7 @@ function FeedbackSection({ pull }: { pull: DerivedPull }) { View on GitHub → diff --git a/frontend-v2/src/toasts.tsx b/frontend-v2/src/toasts.tsx index 40b3876f..a5db9cb4 100644 --- a/frontend-v2/src/toasts.tsx +++ b/frontend-v2/src/toasts.tsx @@ -170,7 +170,7 @@ const SAMPLE_CHEERS: CheerToast[] = [ body: "Waiting 6d with nobody on it — you're the best fit. Claim it?", pull: { repo: 'org/repo', number: 5, title: 'Discourage new files in Exec/ dir' }, actionLabel: 'Claim it', - onAction: () => {}, + onAction: () => undefined, }, ]; diff --git a/frontend-v2/vite.config.ts b/frontend-v2/vite.config.ts index c1b3f5a3..e273cf78 100644 --- a/frontend-v2/vite.config.ts +++ b/frontend-v2/vite.config.ts @@ -1,5 +1,8 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +// eslint-import-resolver-node can't follow exports-only packages (no +// "main"/"module" fallback); tsc and vite both resolve this fine. +// eslint-disable-next-line import/no-unresolved import tailwindcss from '@tailwindcss/vite'; // Served by the express app at / (see app.js); the dev server proxies From 8dfd00fecc89faa72b9ca51b95669076a242d843 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 21:09:44 -0700 Subject: [PATCH 204/381] Stop dropping capped toasts and nagging satisfied re-stamps Three notification-correctness bugs found in a model-layer audit: The MAX_PER_TICK cap silently discarded toasts instead of deferring them. The baseline recorded every event as "seen" whether or not its toast survived the cap, so an evicted nudge (quick-wins, re-stamp-owed, your-turn, an author-side PR-went-green edge) could never fire again. Each push now carries an undo closure that reverts exactly its own "already seen" bookkeeping when the cap evicts it, so the edge is still live next tick. The re-stamp loop's own pre-cap slice had the same leak, fixed the same way. recrBy/reqaBy included stale signers even after other reviewers satisfied the requirement, so a fully signed-off pull could still tell its stale reviewer "Re-stamp", match is:restamp, inflate the re-stamp debt stat, and trigger the re-stamp-owed nag. derive() now empties them once crMet/qaMet holds - a stale stamp only owes a re-stamp while the requirement is unmet. The baseline never recorded whose history it was, so a viewer change between ticks diffed the new user against the old user's stamps (every pre-existing stamp would land as a fresh cheer). The baseline now carries the login and re-primes on mismatch; pre-field session blobs revive as '' and are treated as the current viewer so a deploy doesn't replay start-here in every open tab. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/cheers.test.ts | 55 ++++ frontend-v2/src/model/cheers.ts | 451 +++++++++++++++++---------- frontend-v2/src/model/status.test.ts | 24 ++ frontend-v2/src/model/status.ts | 10 +- 4 files changed, 368 insertions(+), 172 deletions(-) diff --git a/frontend-v2/src/model/cheers.test.ts b/frontend-v2/src/model/cheers.test.ts index 2e23e30d..428377c8 100644 --- a/frontend-v2/src/model/cheers.test.ts +++ b/frontend-v2/src/model/cheers.test.ts @@ -591,6 +591,61 @@ describe('diffCheers — MAX_PER_TICK', () => { expect(toasts.some(t => t.icon === '🔁')).toBe(true); expect(toasts.some(t => t.icon === '⚡')).toBe(false); }); + + it('defers an evicted toast to the next tick instead of losing it', () => { + // same overflow as above: quick-wins (lowest priority) is evicted + const base = primed(sig({ review: 5, restampKeys: new Set(), quickWinCount: 0 })); + const p1 = pull('org/a', 1); + const p2 = pull('org/a', 2, { ageDays: 4 }); + const turns = new Map([[pullKey(p2.data), p2]]); + const overflow = () => + sig({ + review: 0, + restampKeys: new Set([pullKey(p1.data)]), + pulls: [p1, p2], + turns, + quickWinCount: 3, + }); + const first = diffCheers(overflow(), 'me', base); + expect(first.toasts.some(t => t.icon === '⚡')).toBe(false); + // the eviction must NOT bake "already nagged" into the baseline — + // with nothing else newly firing, the quick-wins nag gets its slot now + expect(first.next.quickWinsNagged).toBe(false); + const second = diffCheers(overflow(), 'me', first.next); + expect(second.toasts.some(t => t.icon === '⚡')).toBe(true); + // and the survivors from tick one stay quiet (their marks stood) + expect(second.toasts.some(t => t.dedupeKey === 'board:clear')).toBe(false); + expect(second.toasts.some(t => t.icon === '⏳')).toBe(false); + expect(second.toasts.some(t => t.icon === '🔁')).toBe(false); + }); +}); + +describe('diffCheers — viewer identity', () => { + it('re-primes silently when the baseline belongs to someone else', () => { + // alice's primed baseline has no stamps; bob's board shows one of his. + // Diffing bob against alice's history must not read bob's pre-existing + // stamp as freshly landed — a login mismatch re-primes instead. + const aliceBase = primed(sig(), 'alice'); + const { toasts, next } = diffCheers(sig({ stamped: new Set(['org/a#1']) }), 'bob', aliceBase); + expect(toasts.filter(t => t.dedupeKey?.startsWith('stamp:'))).toHaveLength(0); + expect(next.login).toBe('bob'); + expect(next.stamped.has('org/a#1')).toBe(true); + }); + + it('treats a legacy baseline with no login as the current viewer', () => { + // pre-login-field session blobs revive with login '' — carrying on + // (not re-priming) keeps a deploy from replaying start-here everywhere + const legacy: CheerBaseline = { ...primed(sig(), 'me'), login: '' }; + const p = pull('org/a', 3, { author: 'alice' }); + const { toasts, next } = diffCheers( + sig({ stamped: new Set([pullKey(p.data)]), pulls: [p] }), + 'me', + legacy + ); + // still diffs: the new stamp lands as a cheer rather than re-priming + expect(toasts.some(t => t.dedupeKey?.startsWith('stamp:'))).toBe(true); + expect(next.login).toBe('me'); + }); }); describe('baseline persistence', () => { diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index ea438504..f6c0dca5 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -34,6 +34,10 @@ export type CheerToast = Toast; * numbers) so a caller can hold it in a ref and a test can assert on it. */ export interface CheerBaseline { primed: boolean; + /** whose history this is — a baseline primed for one viewer must never be + * diffed against another's signals (every pre-existing stamp would read as + * freshly landed), so a login mismatch re-primes. '' = legacy/unprimed. */ + login: string; /** pull keys you currently hold a CR/QA stamp on */ stamped: ReadonlySet; /** your open review/qa/restamp count last tick */ @@ -74,6 +78,7 @@ export interface CheerBaseline { export const EMPTY_BASELINE: CheerBaseline = { primed: false, + login: '', stamped: new Set(), queue: 0, nagLevel: 0, @@ -102,6 +107,7 @@ export const EMPTY_BASELINE: CheerBaseline = { export function serializeBaseline(b: CheerBaseline): unknown { return { primed: b.primed, + login: b.login, stamped: [...b.stamped], queue: b.queue, nagLevel: b.nagLevel, @@ -130,6 +136,9 @@ export function reviveBaseline(raw: unknown): CheerBaseline | null { try { return { primed: !!o.primed, + // pre-login-field blobs revive as '' — treated as "unknown, keep", + // not as a mismatch, so a deploy doesn't re-prime every open tab + login: typeof o.login === 'string' ? o.login : '', stamped: new Set(arr(o.stamped) as string[]), queue: Number(o.queue) || 0, nagLevel: Number(o.nagLevel) || 0, @@ -639,7 +648,7 @@ export function diffCheers( base: CheerBaseline, muted: ReadonlySet = NO_MUTED ): { toasts: CheerToast[]; next: CheerBaseline } { - if (!base.primed || !me) { + if (!base.primed || !me || (base.login !== '' && base.login !== me)) { const toasts: CheerToast[] = []; if (me && sig.bestStart && sig.backlog > 0 && !muted.has('start-here')) { toasts.push(startHereToast(sig.bestStart, sig.startReason)); @@ -648,6 +657,7 @@ export function diffCheers( toasts, next: { primed: !!me, + login: me, stamped: sig.stamped, queue: sig.queue, nagLevel: nagStepFor(sig.queue), @@ -668,12 +678,14 @@ export function diffCheers( }; } - const entries: { toast: CheerToast; kind: ToastKind }[] = []; + const entries: { toast: CheerToast; kind: ToastKind; undo?: () => void }[] = []; // a muted kind is dropped here, before the priority sort and cap — the // bookkeeping around each push (session tallies, "already seen" sets) still - // runs, so unmuting later never replays a backlog. - const push = (kind: ToastKind, toast: CheerToast) => { - if (!muted.has(kind)) entries.push({ toast, kind }); + // runs, so unmuting later never replays a backlog. `undo` reverts that + // bookkeeping when the per-tick cap evicts the toast: an evicted toast is + // deferred (the same edge re-fires next tick), never silently lost. + const push = (kind: ToastKind, toast: CheerToast, undo?: () => void) => { + if (!muted.has(kind)) entries.push({ toast, kind, undo }); }; let sessionStamps = base.sessionStamps; @@ -707,73 +719,97 @@ export function diffCheers( for (const m of MILESTONES) { if (sessionStamps >= m && !firedMilestones.has(m)) { firedMilestones.add(m); - push('milestone', { - tone: 'reward', - icon: '🔥', - title: 'reviews this sitting', - count: sessionStamps, - body: pick(MILESTONE_PRAISE, sessionStamps), - celebrate: true, - dedupeKey: `milestone:${m}`, - }); + push( + 'milestone', + { + tone: 'reward', + icon: '🔥', + title: 'reviews this sitting', + count: sessionStamps, + body: pick(MILESTONE_PRAISE, sessionStamps), + celebrate: true, + dedupeKey: `milestone:${m}`, + }, + () => firedMilestones.delete(m) + ); } } - // queue cleared: the hero moment + // queue cleared: the hero moment. On eviction the baseline keeps the old + // queue count, so the >0 → 0 edge is still there to fire next tick. + let nextQueue = sig.queue; if (base.queue > 0 && sig.queue === 0) { - push('inbox-zero', { - tone: 'reward', - icon: '🎉', - title: 'Inbox zero', - body: "Nothing's waiting on you.", - celebrate: true, - shimmer: true, - dedupeKey: 'inbox:zero', - }); + push( + 'inbox-zero', + { + tone: 'reward', + icon: '🎉', + title: 'Inbox zero', + body: "Nothing's waiting on you.", + celebrate: true, + shimmer: true, + dedupeKey: 'inbox:zero', + }, + () => { + nextQueue = base.queue; + } + ); } // the rotation newly named you on a starved pull. Next tick's seenTurns is // just the current turn keys — a pull that stops being yours is forgotten, // so if it later comes back around it can nag again. + const seenTurns = new Set(sig.turns.keys()); for (const [key, p] of sig.turns) { if (base.seenTurns.has(key)) continue; - push('your-turn', { - tone: 'nag', - icon: '⏳', - title: 'Your turn to review', - body: `Waiting ${Math.max(1, Math.round(p.ageDays))}d with nobody on it — you're the best fit. Claim it?`, - pull: pullRef(p), - actionLabel: 'Claim it', - dedupeKey: `turn:${key}`, - }); + push( + 'your-turn', + { + tone: 'nag', + icon: '⏳', + title: 'Your turn to review', + body: `Waiting ${Math.max(1, Math.round(p.ageDays))}d with nobody on it — you're the best fit. Claim it?`, + pull: pullRef(p), + actionLabel: 'Claim it', + dedupeKey: `turn:${key}`, + }, + () => seenTurns.delete(key) + ); } - const seenTurns = new Set(sig.turns.keys()); // review requested: GitHub asked you directly. Fires once per pull (rebuilt // from the current tick, seenTurns-style, so a dropped-then-re-added request // can nag again). Distinct from your-turn: this is an explicit ask, not the // rotation's guess — which is why turnFor stays silent on requested pulls. + const requestedSeen = new Set(sig.requestedOfMe.keys()); for (const [key, p] of sig.requestedOfMe) { if (base.requestedSeen.has(key)) continue; - push('review-requested', { - tone: 'info', - icon: '✦', - title: 'Review requested', - body: `${p.data.user.login} asked you to review this.`, - pull: pullRef(p), - dedupeKey: `req:${key}`, - }); + push( + 'review-requested', + { + tone: 'info', + icon: '✦', + title: 'Review requested', + body: `${p.data.user.login} asked you to review this.`, + pull: pullRef(p), + dedupeKey: `req:${key}`, + }, + () => requestedSeen.delete(key) + ); } - const requestedSeen = new Set(sig.requestedOfMe.keys()); // start-here: the backlog re-arms after hitting zero (the prime-tick fire // is handled above, before this function's main body ever runs). + let nextHadBacklog = sig.backlog > 0; if (!base.hadBacklog && sig.backlog > 0 && sig.bestStart) { - push('start-here', startHereToast(sig.bestStart, sig.startReason)); + push('start-here', startHereToast(sig.bestStart, sig.startReason), () => { + nextHadBacklog = false; + }); } // re-stamp owed: one toast per pull newly needing another look, replacing // the old aggregate "a stamp went stale" nag with something you can act on. + const restampKeys = new Set(sig.restampKeys); const newRestamps: DerivedPull[] = []; for (const key of sig.restampKeys) { if (base.restampKeys.has(key)) continue; @@ -781,44 +817,62 @@ export function diffCheers( if (p) newRestamps.push(p); } for (const p of newRestamps.slice(0, MAX_PER_TICK)) { - push('re-stamp-owed', { - tone: 'nag', - icon: '🔁', - title: 'Changed since your ✓', - body: 'Give it another look?', - pull: pullRef(p), - dedupeKey: `recr:${pullKey(p.data)}`, - }); + const key = pullKey(p.data); + push( + 're-stamp-owed', + { + tone: 'nag', + icon: '🔁', + title: 'Changed since your ✓', + body: 'Give it another look?', + pull: pullRef(p), + dedupeKey: `recr:${key}`, + }, + () => restampKeys.delete(key) + ); } + // restamps past this loop's own slice were never offered at all — leave + // them unmarked so they get their turn on a later tick + for (const p of newRestamps.slice(MAX_PER_TICK)) restampKeys.delete(pullKey(p.data)); // a claim of yours went stale (2h+) and you still haven't stamped it — // nudge once per pull to finish it or hand it back. seenTurns-style rebuild // from the current tick, so releasing then re-claiming can nag again. + const staleClaimsSeen = new Set(sig.staleClaims.keys()); for (const [key, p] of sig.staleClaims) { if (base.staleClaimsSeen.has(key)) continue; - push('claim-stale', { - tone: 'nag', - icon: '✋', - title: 'You claimed this — still unreviewed', - body: "It's been a couple hours. Review it, or release it for someone else.", - pull: pullRef(p), - dedupeKey: `claimstale:${key}`, - }); + push( + 'claim-stale', + { + tone: 'nag', + icon: '✋', + title: 'You claimed this — still unreviewed', + body: "It's been a couple hours. Review it, or release it for someone else.", + pull: pullRef(p), + dedupeKey: `claimstale:${key}`, + }, + () => staleClaimsSeen.delete(key) + ); } - const staleClaimsSeen = new Set(sig.staleClaims.keys()); // quick wins: the pile crossed the threshold going up let quickWinsNagged = base.quickWinsNagged; if (sig.quickWinCount >= QUICK_WIN_THRESHOLD && !quickWinsNagged) { quickWinsNagged = true; - push('quick-wins', { - tone: 'info', - icon: '⚡', - title: `${sig.quickWinCount} quick reviews on the board`, - body: 'XS/S — small ones, unclaimed.', - pull: sig.quickWinPull ? pullRef(sig.quickWinPull) : undefined, - dedupeKey: `quick:${sig.quickWinCount}`, - }); + push( + 'quick-wins', + { + tone: 'info', + icon: '⚡', + title: `${sig.quickWinCount} quick reviews on the board`, + body: 'XS/S — small ones, unclaimed.', + pull: sig.quickWinPull ? pullRef(sig.quickWinPull) : undefined, + dedupeKey: `quick:${sig.quickWinCount}`, + }, + () => { + quickWinsNagged = false; + } + ); } else if (sig.quickWinCount < QUICK_WIN_THRESHOLD) { quickWinsNagged = false; } @@ -828,31 +882,44 @@ export function diffCheers( for (const { login, pull: p, count } of sig.debtors) { if (favorsSeen.has(login)) continue; favorsSeen.add(login); - push('return-the-favor', { - tone: 'info', - icon: '🤝', - title: `Return the favor to ${login}`, - body: - count === 1 - ? 'They reviewed one of your PRs.' - : `They've reviewed ${count} of your PRs.`, - pull: pullRef(p), - dedupeKey: `favor:${login}`, - }); + push( + 'return-the-favor', + { + tone: 'info', + icon: '🤝', + title: `Return the favor to ${login}`, + body: + count === 1 + ? 'They reviewed one of your PRs.' + : `They've reviewed ${count} of your PRs.`, + pull: pullRef(p), + dedupeKey: `favor:${login}`, + }, + () => favorsSeen.delete(login) + ); } - // leaderboard: top and climbing + // leaderboard: top and climbing. Evicted edges hold last tick's rank/top + // state in the baseline so they re-fire while the standing persists. + let nextWasTop = sig.myRank === 1; + let nextMyRank = sig.myRank; if (sig.myRank === 1 && !base.wasTop && sig.myCount > 0) { - push('top-of-board', { - tone: 'reward', - icon: '🏆', - title: 'stamps in view', - count: sig.myCount, - body: "Top of the board — nobody's ahead of you.", - celebrate: true, - shimmer: true, - dedupeKey: 'top:1', - }); + push( + 'top-of-board', + { + tone: 'reward', + icon: '🏆', + title: 'stamps in view', + count: sig.myCount, + body: "Top of the board — nobody's ahead of you.", + celebrate: true, + shimmer: true, + dedupeKey: 'top:1', + }, + () => { + nextWasTop = false; + } + ); } if ( base.myRank > 0 && @@ -862,100 +929,144 @@ export function diffCheers( sig.myCount >= 2 && sig.peerBelow ) { - push('climbing', { - tone: 'reward', - icon: '📈', - title: `You passed ${sig.peerBelow}`, - body: `#${sig.myRank} reviewer on the board.`, - celebrate: true, - dedupeKey: `rank:${sig.myRank}`, - }); + push( + 'climbing', + { + tone: 'reward', + icon: '📈', + title: `You passed ${sig.peerBelow}`, + body: `#${sig.myRank} reviewer on the board.`, + celebrate: true, + dedupeKey: `rank:${sig.myRank}`, + }, + () => { + nextMyRank = base.myRank; + } + ); } // author-side: your own open PRs, watched for edge transitions only — a // brand-new key (a PR you just opened) has no baseline entry yet, so it // primes silently here rather than firing on however it first appears. + const nextAuthorPrs = new Map(sig.authorPrs); + // an evicted author edge writes the OLD flag back into the carried state + // (reading the map entry fresh, so two undone edges on one pull compose) + const keepEdge = (key: string, patch: Partial) => { + const cur = nextAuthorPrs.get(key); + if (cur) nextAuthorPrs.set(key, { ...cur, ...patch }); + }; for (const [key, now] of sig.authorPrs) { const was = base.authorPrs.get(key); if (!was) continue; const p = sig.byKey.get(key); if (!p) continue; if (!was.green && now.green) { - push('pr-green', { - tone: 'reward', - icon: '🚀', - title: 'Green — ship it', - body: 'CR + QA both cleared.', - pull: pullRef(p), - celebrate: true, - shimmer: true, - dedupeKey: `green:${key}`, - }); + push( + 'pr-green', + { + tone: 'reward', + icon: '🚀', + title: 'Green — ship it', + body: 'CR + QA both cleared.', + pull: pullRef(p), + celebrate: true, + shimmer: true, + dedupeKey: `green:${key}`, + }, + () => keepEdge(key, { green: false }) + ); } if (!was.reviewed && now.reviewed) { - push('pr-first-review', { - tone: 'info', - icon: '👀', - title: 'Someone picked up your PR', - body: `${p.crBy[0] ?? 'A reviewer'} is on it.`, - pull: pullRef(p), - dedupeKey: `firstrev:${key}`, - }); + push( + 'pr-first-review', + { + tone: 'info', + icon: '👀', + title: 'Someone picked up your PR', + body: `${p.crBy[0] ?? 'A reviewer'} is on it.`, + pull: pullRef(p), + dedupeKey: `firstrev:${key}`, + }, + () => keepEdge(key, { reviewed: false }) + ); } if (!was.conflict && now.conflict) { - push('pr-conflicts', { - tone: 'nag', - icon: '⚠️', - title: 'Conflicts on your PR', - body: 'Needs a rebase.', - pull: pullRef(p), - dedupeKey: `conflict:${key}`, - }); + push( + 'pr-conflicts', + { + tone: 'nag', + icon: '⚠️', + title: 'Conflicts on your PR', + body: 'Needs a rebase.', + pull: pullRef(p), + dedupeKey: `conflict:${key}`, + }, + () => keepEdge(key, { conflict: false }) + ); } if (!was.ciRed && now.ciRed) { - push('pr-ci-red', { - tone: 'nag', - icon: '🔴', - title: 'CI broke on your PR', - body: p.ciFailing?.length ? `Fix ${p.ciFailing.join(', ')}.` : 'Fix the build.', - pull: pullRef(p), - dedupeKey: `cired:${key}`, - }); + push( + 'pr-ci-red', + { + tone: 'nag', + icon: '🔴', + title: 'CI broke on your PR', + body: p.ciFailing?.length ? `Fix ${p.ciFailing.join(', ')}.` : 'Fix the build.', + pull: pullRef(p), + dedupeKey: `cired:${key}`, + }, + () => keepEdge(key, { ciRed: false }) + ); } if (!was.needsAnswer && now.needsAnswer) { - push('pr-changes', { - tone: 'nag', - icon: '💬', - title: 'Changes requested on your PR', - body: 'A reviewer wants edits — answer the feedback.', - pull: pullRef(p), - dedupeKey: `changes:${key}`, - }); + push( + 'pr-changes', + { + tone: 'nag', + icon: '💬', + title: 'Changes requested on your PR', + body: 'A reviewer wants edits — answer the feedback.', + pull: pullRef(p), + dedupeKey: `changes:${key}`, + }, + () => keepEdge(key, { needsAnswer: false }) + ); } if (!was.starved && now.starved) { - push('pr-starving', { - tone: 'nag', - icon: '🕰️', - title: `Waiting ${p.ageDays}d for review`, - body: 'Your PR — worth a nudge?', - pull: pullRef(p), - dedupeKey: `starve:${key}`, - }); + push( + 'pr-starving', + { + tone: 'nag', + icon: '🕰️', + title: `Waiting ${p.ageDays}d for review`, + body: 'Your PR — worth a nudge?', + pull: pullRef(p), + dedupeKey: `starve:${key}`, + }, + () => keepEdge(key, { starved: false }) + ); } } // board cleared: the whole board's awaiting-review count dropped to zero — // board-wide, so this is a rare all-hands moment, not just your queue + let nextBoardQueue = sig.boardReviewable; if (base.boardQueue > 0 && sig.boardReviewable === 0) { - push('board-cleared', { - tone: 'reward', - icon: '🎊', - title: "Board's clear", - body: 'Nothing waiting on anyone.', - celebrate: true, - shimmer: true, - dedupeKey: 'board:clear', - }); + push( + 'board-cleared', + { + tone: 'reward', + icon: '🎊', + title: "Board's clear", + body: 'Nothing waiting on anyone.', + celebrate: true, + shimmer: true, + dedupeKey: 'board:clear', + }, + () => { + nextBoardQueue = base.boardQueue; + } + ); } // ── nagLevel bookkeeping only (no toast fires from this anymore — the old @@ -965,30 +1076,32 @@ export function diffCheers( if (sig.queue === 0) nagLevel = 0; else nagLevel = step; - const toasts = entries - .sort((a, b) => PRIORITY[b.kind] - PRIORITY[a.kind]) - .slice(0, MAX_PER_TICK) - .map(e => e.toast); + const ranked = entries.sort((a, b) => PRIORITY[b.kind] - PRIORITY[a.kind]); + // the cap defers, it doesn't discard: whatever it evicts gets its "already + // seen" bookkeeping undone, so the same edge is still live next tick + for (const evicted of ranked.slice(MAX_PER_TICK)) evicted.undo?.(); + const toasts = ranked.slice(0, MAX_PER_TICK).map(e => e.toast); return { toasts, next: { primed: true, + login: me, stamped: sig.stamped, - queue: sig.queue, + queue: nextQueue, nagLevel, sessionStamps, firedMilestones, seenTurns, - restampKeys: sig.restampKeys, + restampKeys, quickWinsNagged, favorsSeen, - myRank: sig.myRank, - wasTop: sig.myRank === 1, + myRank: nextMyRank, + wasTop: nextWasTop, requestedSeen, - hadBacklog: sig.backlog > 0, - authorPrs: sig.authorPrs, - boardQueue: sig.boardReviewable, + hadBacklog: nextHadBacklog, + authorPrs: nextAuthorPrs, + boardQueue: nextBoardQueue, staleClaimsSeen, }, }; diff --git a/frontend-v2/src/model/status.test.ts b/frontend-v2/src/model/status.test.ts index 6c9d9212..e4471e31 100644 --- a/frontend-v2/src/model/status.test.ts +++ b/frontend-v2/src/model/status.test.ts @@ -237,6 +237,30 @@ describe('status derivation precedence', () => { expect(d.reqaBy).toEqual(['tester']); }); + it('a stale stamp owes nothing once others meet the requirement', () => { + // carol's CR went stale, but alice + bob re-approved: the pull is ready + // and carol must NOT still be told "Re-stamp" (or counted in the + // re-stamp debt / is:restamp query) for a fully signed-off pull + const p = withStatus({ + cr_req: 2, + allCR: [sig('CR', 'alice', true), sig('CR', 'bob', true), sig('CR', 'carol', false)], + allQA: [sig('QA', 'q', true)], + }); + const d = derive(p, undefined, NOW); + expect(d.status).toBe('ready'); + expect(d.recrBy).toEqual([]); + }); + + it('a stale QA owes nothing once another QA stamp lands', () => { + const p = withStatus({ + allCR: [sig('CR', 'r', true)], + allQA: [sig('QA', 'tester', false), sig('QA', 'other', true)], + }); + const d = derive(p, undefined, NOW); + expect(d.status).toBe('ready'); + expect(d.reqaBy).toEqual([]); + }); + it('unknown mergeability is flagged, not asserted', () => { const p = withStatus( { allCR: [sig('CR', 'r', true)], allQA: [sig('QA', 'q', true)] }, diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index 80be871b..54351064 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -65,7 +65,9 @@ export interface DerivedPull { qaBy: string[]; crHave: number; qaHave: number; - /** users whose CR a later push invalidated and who haven't re-stamped */ + /** users whose CR a later push invalidated and whose re-stamp is still + * needed — empty once other active stamps satisfy cr_req, so nobody gets + * nagged to re-review a pull that's already fully signed off */ recrBy: string[]; /** users whose QA stamp a later push invalidated (symmetric with recrBy) */ reqaBy: string[]; @@ -293,8 +295,10 @@ export function derive( qaBy, crHave, qaHave, - recrBy: staleCr, - reqaBy: staleQa, + // a stale stamp only owes a re-stamp while the requirement is unmet — + // once others satisfy it, nothing is asked of the stale signer + recrBy: crMet ? [] : staleCr, + reqaBy: qaMet ? [] : staleQa, headPushedAt: headPushedAt(pull), ageDays, signedOffAt, From 12a4d5155917f95aa6329768a92522b59b967c55 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 21:10:02 -0700 Subject: [PATCH 205/381] Polish cards, toasts, and charts from a UI audit Card-level dedup - one fact should render once per card: - The state popover computed "your move" without the claim/turn context the row itself uses, so it could say "your move: Review it" on a pull someone else had claimed while the row's do-pill was rightly silent. StateSection now receives claim/turn and reads the same coordinated note. - A freshly requested pull stacked four renderings of one fact (Review it pill, review-requested chip, "requested from you" context text, the badge). The chip carries it now; the context line drops the redundant prefix. - The aging lane's flag chip repeated the rail AgeStamp's day count ("5d" and "open 5d" side by side). The chip now names why the card is in the lane; the numbers live in its hover detail. - SigPips stacked a native title tooltip under its own hover popover saying the same fraction - the exact double-tooltip AgeStamp already solved with aria-label. Pips drop the title when the popover wraps them; the bare-pips fallback keeps it. Toast stack: - A toast evicted by the visible-stack cap never fired onGone, so the "shipped while you were away" toast could vanish under a burst of cheers without advancing the read-state, stranding the shipped fold unread forever. Eviction now fires onGone like a dismissal, with a fired-once guard for StrictMode's double-invoked updaters. - A clickable toast announced as role=status with no hint it responds to Enter; it's role=button now (the polite live region still announces). - The card's overflow-hidden (needed for the shimmer sweep) cropped the sparkle burst, whose glints intentionally fly past the card edge. The sweep gets its own clipping layer; the card unclips. Chart + cleanup: - LineChart gridlines referenced var(--line), which doesn't exist, so they resolved to stroke:none and silently never rendered in any theme. They use var(--border) now. - Drop the dead .spark keyframes (replaced by sparkle-burst) and the inert toast-card class. - Deleting a saved filter now arms on first click and removes on the second (4s auto-disarm), same pattern as Settings' "Clear settings" - a hand-tuned query shouldn't die to one misclick. - jumpToShipped is memoized; its fresh identity each render re-ran the shippedExtras -> useToasts extras chain on every unrelated keystroke. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 41 +++++++----- frontend-v2/src/components/Row.tsx | 13 +++- .../src/components/SavedFiltersPanel.tsx | 37 +++++++++-- frontend-v2/src/components/StatePopover.tsx | 21 +++++-- frontend-v2/src/components/bits.tsx | 23 +++++-- frontend-v2/src/styles.css | 16 ----- frontend-v2/src/toasts.tsx | 62 +++++++++++++------ frontend-v2/src/views/stats/parts.tsx | 2 +- 8 files changed, 148 insertions(+), 67 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 60de21f9..967e1ca5 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -730,23 +730,30 @@ export function App() { : lens === 'mine' ? closed.filter(p => p.user.login === me).length : 0; - const jumpToShipped = shippedFoldId - ? () => { - const id = shippedFoldId; - openFold(id); - // openFold's write lands via useSyncExternalStore, so the fold's - // `open` attribute (and the height its rows add) commits on the - // next paint, not synchronously in this handler — wait a frame so - // the scroll lands on the expanded fold, not the collapsed one. - requestAnimationFrame(() => { - const el = document.getElementById(foldDomId(id)); - if (!el) return; - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); - el.classList.add('fold-flash'); - setTimeout(() => el.classList.remove('fold-flash'), 900); - }); - } - : undefined; + // memoized: this feeds the shippedExtras useMemo below, which feeds + // useToasts' extras effect — a fresh identity every render would re-run + // that chain on every unrelated keystroke/toggle while on these lenses + const jumpToShipped = useMemo( + () => + shippedFoldId + ? () => { + const id = shippedFoldId; + openFold(id); + // openFold's write lands via useSyncExternalStore, so the fold's + // `open` attribute (and the height its rows add) commits on the + // next paint, not synchronously in this handler — wait a frame so + // the scroll lands on the expanded fold, not the collapsed one. + requestAnimationFrame(() => { + const el = document.getElementById(foldDomId(id)); + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + el.classList.add('fold-flash'); + setTimeout(() => el.classList.remove('fold-flash'), 900); + }); + } + : undefined, + [shippedFoldId] + ); // the "shipped while you were away" catch-up, now a single info toast instead // of a standing banner. Scope-filtered (respects the active repo/author/hidden diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 79645724..ae35e652 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -171,7 +171,9 @@ function rowFlags( flags.push({ key: 'aging', tone: 'warn', - label: `open ${p.ageDays}d`, + // the rail's AgeStamp already shows the day count on this same card — + // the chip names WHY it's in the aging lane, the hover has the numbers + label: 'aging', detail: `Open ${p.ageDays} days without full CR (${p.crHave} of ${p.data.status.cr_req}).${quiet}`, }); } @@ -722,6 +724,11 @@ function RowImpl({ // only worth asking the whole-board lookup when this row is stacked but // rendering flat (its parent isn't visible right above it already) const orphanParent = pull.dependent && depth === 0 ? (opts.parentOf?.(pull) ?? null) : null; + // the "review requested" chip already states this — strip it from the + // context line so one card doesn't carry the same fact as chip AND text + const contextText = requestedOfMe + ? note.context?.replace(/^requested from you( · )?/, '') || null + : note.context; return ( )} - {note.context && ( + {contextText && ( void; onRemove?: () => void; }) { + // arm-then-confirm, same pattern as Settings' "Clear settings": a saved + // filter can be a hand-tuned query, so a single misclick on the ✕ must not + // erase it — first click arms for 4s, the second actually removes + const [armed, setArmed] = useState(false); + const disarm = useRef | null>(null); + useEffect( + () => () => { + if (disarm.current) clearTimeout(disarm.current); + }, + [] + ); return (
{describeHash(filter.hash)} {onRemove && ( - + { e.stopPropagation(); + if (!armed) { + setArmed(true); + disarm.current = setTimeout(() => setArmed(false), 4000); + return; + } + if (disarm.current) clearTimeout(disarm.current); onRemove(); }} - aria-label={`remove saved filter ${filter.name}`} - title="remove this saved filter" + aria-label={ + armed + ? `confirm removing saved filter ${filter.name}` + : `remove saved filter ${filter.name}` + } + title={armed ? 'click again to remove' : 'remove this saved filter'} > - ✕ + {armed ? ( + sure? + ) : ( + '✕' + )} )} diff --git a/frontend-v2/src/components/StatePopover.tsx b/frontend-v2/src/components/StatePopover.tsx index 0c6580a5..7dbaf4c9 100644 --- a/frontend-v2/src/components/StatePopover.tsx +++ b/frontend-v2/src/components/StatePopover.tsx @@ -80,9 +80,22 @@ function stateExplanation(pull: DerivedPull, note: RowNote): string { } /** Section 1: what this pull's badge means, restated in a sentence, plus the - * viewer's own move when they have one. */ -function StateSection({ pull, me }: { pull: DerivedPull; me: string }) { - const note = rowNote(pull, me); + * viewer's own move when they have one. Claim/turn must flow into rowNote + * here exactly as they do on the row itself — otherwise the popover asserts + * "your move: Review it" on a pull someone else has already claimed, flatly + * contradicting the row's own (rightly silent) do-pill. */ +function StateSection({ + pull, + me, + claim, + turn, +}: { + pull: DerivedPull; + me: string; + claim?: { login: string; at: number } | null; + turn?: string | null; +}) { + const note = rowNote(pull, me, { claim, turn }); return (

{STATUS_LABEL[pull.status]}

@@ -211,7 +224,7 @@ function StatePopoverBody({ }) { return ( <> - +
= req; @@ -267,7 +273,11 @@ export function Pips({ : `${label} done` : `${label}: ${have} of ${req}`; return ( - + {label} @@ -320,8 +330,13 @@ export function SigPips({ me?: string; sigs: Signature[]; }) { - const pips = ; - if (!sigs.length) return pips; + // bare pips (the no-signatures fallback) keep their native title; inside + // the hover popover below it would just stack a browser tooltip on top + if (!sigs.length) + return ; + const pips = ( + + ); // latest signature per user, live stamps first, then invalidated ones const latest = new Map(); @@ -349,7 +364,7 @@ export function SigPips({ {toast.shimmer && ( + // the sweep gets its own clipping layer instead of overflow-hidden + // on the whole card, so the sparkle burst (which flies past the + // card edge by design) never gets cropped mid-celebration + className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]" + > + + )}
); diff --git a/frontend-v2/src/views/stats/parts.tsx b/frontend-v2/src/views/stats/parts.tsx index 56ed268d..aa9ec6b5 100644 --- a/frontend-v2/src/views/stats/parts.tsx +++ b/frontend-v2/src/views/stats/parts.tsx @@ -347,7 +347,7 @@ export function LineChart({ x2={CHART_W} y1={y} y2={y} - stroke="var(--line)" + stroke="var(--border)" strokeWidth={1} /> ))} From 56012b55e9718d72d76b52ac8a3e5d51766fc1a8 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 22:50:31 -0700 Subject: [PATCH 206/381] Fire the shipped nudge only for merged PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing your own PR without merging surfaced a "Shipped while you were away — Your PR landed" toast. The store's `closed` list carries every pull in state 'closed', which on GitHub is merged AND closed-without-merge alike, but shipRelevance only checked authorship and review stamps, never whether the pull actually merged. "Shipped" and "landed" are merge language; an abandoned PR shipped nothing. Gate shipRelevance on merged_at up front: an unmerged close is never relevant, whoever authored or reviewed it, so it produces no toast and drops out of the ranked catch-up. The stats lens already gates every merge metric on merged_at, so this aligns the notification with the rest of the app. The "recently shipped" fold still lists the close (its per-row badge honestly reads "closed" vs "merged"); only the merge-implying toast is suppressed. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/shipped.test.ts | 25 +++++++++++++++++++++++-- frontend-v2/src/model/shipped.ts | 10 ++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/frontend-v2/src/model/shipped.test.ts b/frontend-v2/src/model/shipped.test.ts index 2d114f1d..cdab6c4c 100644 --- a/frontend-v2/src/model/shipped.test.ts +++ b/frontend-v2/src/model/shipped.test.ts @@ -9,15 +9,20 @@ function dp(o: { cr?: string[]; qa?: string[]; closedAt?: string; + /** false = closed without merging; defaults to merged, since these tests + * are about shipped (i.e. merged) pulls */ + merged?: boolean; }): PullData { const sig = (login: string) => ({ data: { user: { login } } }); + const closedAt = o.closedAt ?? '2026-01-01T00:00:00Z'; return { repo: 'org/repo', number: o.number ?? 1, title: `pull ${o.number ?? 1}`, user: { login: o.author ?? 'author' }, - closed_at: o.closedAt ?? '2026-01-01T00:00:00Z', - updated_at: o.closedAt ?? '2026-01-01T00:00:00Z', + closed_at: closedAt, + updated_at: closedAt, + merged_at: o.merged === false ? null : closedAt, status: { allCR: (o.cr ?? []).map(sig), allQA: (o.qa ?? []).map(sig) }, } as unknown as PullData; } @@ -33,6 +38,12 @@ describe('shipRelevance', () => { it('is null when you neither authored nor reviewed it', () => { expect(shipRelevance(dp({ author: 'alice', cr: ['bob'] }), 'me')).toBeNull(); }); + it('is null for a PR closed without merging, even one you authored', () => { + // the reported bug: closing your own PR fired "shipped — your PR landed" + expect(shipRelevance(dp({ author: 'me', merged: false }), 'me')).toBeNull(); + // and one you reviewed but that was closed unmerged never landed either + expect(shipRelevance(dp({ author: 'alice', qa: ['me'], merged: false }), 'me')).toBeNull(); + }); it('prefers yours over reviewed (you can not review your own PR, but be safe)', () => { expect(shipRelevance(dp({ author: 'me', cr: ['me'] }), 'me')).toBe('yours'); }); @@ -66,6 +77,16 @@ describe('shippedToast', () => { expect(shippedToast([other], 'me')).toBeNull(); }); + it('ignores your own unmerged close and counts only merged pulls', () => { + const closedUnmerged = dp({ number: 1, author: 'me', merged: false }); + expect(shippedToast([closedUnmerged], 'me')).toBeNull(); + // a mixed batch counts only the merged one + const merged = dp({ number: 2, author: 'me' }); + const toast = shippedToast([closedUnmerged, merged], 'me'); + expect(toast?.title).toBe('Shipped while you were away'); + expect(toast?.pull?.number).toBe(2); + }); + it('names the pull and yours/reviewed for a single relevant pull', () => { const mine = dp({ number: 42, author: 'me' }); const toast = shippedToast([mine], 'me'); diff --git a/frontend-v2/src/model/shipped.ts b/frontend-v2/src/model/shipped.ts index c9c73129..ce89ed3f 100644 --- a/frontend-v2/src/model/shipped.ts +++ b/frontend-v2/src/model/shipped.ts @@ -11,9 +11,15 @@ import type { Toast } from './toast'; export type ShipRelevance = 'yours' | 'reviewed' | null; /** Why a shipped pull matters to you: you authored it, you stamped it (CR or - * QA, live or since gone stale — you still touched it), or neither. */ + * QA, live or since gone stale — you still touched it), or neither. + * + * "Shipped" means MERGED. The store's `closed` list carries every pull with + * state 'closed', which on GitHub is merged AND closed-without-merge alike — + * but a PR you closed unmerged never landed, so it earns no "shipped … your PR + * landed" nudge. Gate on merged_at up front: an unmerged close is never + * relevant, whoever authored or reviewed it. */ export function shipRelevance(p: PullData, me: string): ShipRelevance { - if (!me) return null; + if (!me || !p.merged_at) return null; if (p.user.login === me) return 'yours'; const reviewed = p.status.allCR.some(s => s.data.user.login === me) || From 476f55a52f37c4588089478445385242237ba967 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 20 Jul 2026 23:18:07 -0700 Subject: [PATCH 207/381] Surface re-stamp/re-QA and self-block moves consistently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review-path semantic mismatches where a card asserted one thing and the lane it lived in did another. reviewerMove gated Re-QA (and Finish QA) on status === 'needs_qa', but QA runs in parallel with CR on this board (see Review's qaPool) and the re-QA obligation is populated independent of status. So a push that invalidated both your QA and someone else's CR at once left the pull at needs_recr while the card still rendered "Re-QA" (rowNote/actionState surface it unconditionally) — yet "Yours to do" silently dropped it and it fell into the generic Needs QA lane. Drop the status guard in reviewerMove and the qaPool exclusion so every surface agrees; the re-stamp obligation is treated the same way. authorMove returned 'Address feedback' for any dev_block, but a block you put on your OWN PR (self-flagged "hold off") isn't feedback to answer — authorNote already special-cases this as 'Lift your block'. The mismatch fired a desktop notification titled "Address feedback" about your own hold and tagged the row's sort key differently from what it rendered. authorMove now mirrors authorNote; 'Lift your block' joins the Yours-to-do verbs and MOVE_RANK, and alertMove treats it as a standing choice (no nudge), like 'Finish the draft'. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/actions.test.ts | 41 +++++++++++++++++++++++++-- frontend-v2/src/model/actions.ts | 32 +++++++++++++++------ frontend-v2/src/views/Review.tsx | 11 ++++--- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/frontend-v2/src/model/actions.test.ts b/frontend-v2/src/model/actions.test.ts index d3a5107f..f26e52c8 100644 --- a/frontend-v2/src/model/actions.test.ts +++ b/frontend-v2/src/model/actions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { ago } from '../format'; import type { DerivedPull, Status } from './status'; import { STATUS_ORDER } from './status'; -import { actionState, alertMove, rowNote } from './actions'; +import { actionState, alertMove, authorMove, reviewerMove, rowNote } from './actions'; /** A DerivedPull with only the fields the move functions read. */ function dp(o: { @@ -65,10 +65,21 @@ describe('alertMove — which transitions earn a desktop nudge', () => { it('alerts the author on real transitions to their own PR', () => { expect(alertMove(dp({ author: 'me', status: 'ready' }), 'me')).toBe('Merge it'); expect(alertMove(dp({ author: 'me', status: 'ci_red' }), 'me')).toBe('Fix CI'); - expect(alertMove(dp({ author: 'me', status: 'dev_block' }), 'me')).toBe('Address feedback'); + // a dev block held by someone else is feedback that landed on you + expect( + alertMove(dp({ author: 'me', status: 'dev_block', devBlockedBy: ['reviewer'] }), 'me') + ).toBe('Address feedback'); expect(alertMove(dp({ author: 'me', status: 'unmergeable' }), 'me')).toBe('Rebase'); }); + it('does not nudge you about a block you put on your own PR', () => { + // self-only blocker → authorMove is 'Lift your block', a standing choice, + // not an event, so no desktop nudge fires + expect( + alertMove(dp({ author: 'me', status: 'dev_block', devBlockedBy: ['me'] }), 'me') + ).toBeNull(); + }); + it('does not alert on self-initiated / standing states', () => { // "Find a QA-er" and "Finish the draft" are conditions, not events expect(alertMove(dp({ author: 'me', status: 'needs_qa' }), 'me')).toBeNull(); @@ -96,6 +107,32 @@ describe('alertMove — which transitions earn a desktop nudge', () => { }); }); +describe('authorMove — a dev block you hold yourself', () => { + it('says lift-your-block when you are the only blocker, not address-feedback', () => { + expect(authorMove(dp({ author: 'me', status: 'dev_block', devBlockedBy: ['me'] }))).toBe( + 'Lift your block' + ); + }); + it('still says address-feedback when someone else holds the block', () => { + expect( + authorMove(dp({ author: 'me', status: 'dev_block', devBlockedBy: ['me', 'reviewer'] })) + ).toBe('Address feedback'); + }); +}); + +describe('reviewerMove — re-stamp/re-QA are not gated on the current status', () => { + it('surfaces an owed Re-QA even when the pull sits at needs_recr', () => { + // a push invalidated both your QA and someone else's CR at once: status + // lands at needs_recr (CR precedes QA), but the re-QA is still yours to do + const p = dp({ author: 'a', status: 'needs_recr', recrBy: ['alice'], reqaBy: ['me'] }); + expect(reviewerMove(p, 'me')).toBe('Re-QA'); + }); + it('surfaces an owed Re-stamp whatever higher-precedence state masks it', () => { + const p = dp({ author: 'a', status: 'ci_red', recrBy: ['me'] }); + expect(reviewerMove(p, 'me')).toBe('Re-stamp'); + }); +}); + const note = (o: Parameters[0], me = 'author') => rowNote(dp(o), me); describe('rowNote — the author matrix', () => { diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index 689e5286..2cf59826 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -8,25 +8,37 @@ import type { DerivedPull } from './status'; * two tabs can never disagree about what you owe. */ -/** Your move on a pull you authored; null = waiting on someone else. */ +/** Your move on a pull you authored; null = waiting on someone else. Only ever + * called for the viewer's own pulls, so the author IS the viewer here. */ export function authorMove(p: DerivedPull): string | null { if (p.status === 'ready') return 'Merge it'; if (p.status === 'ci_red') return 'Fix CI'; // a dev block is feedback waiting on YOU — v1 lore said "ask them to lift - // it", which misroutes the most common author action - if (p.status === 'dev_block') return 'Address feedback'; + // it", which misroutes the most common author action. But a block you put + // on your own PR (self-flagged "hold off") isn't feedback to answer — the + // move is lifting it, so mirror authorNote's self-only-blocker case rather + // than telling you to "Address feedback" from yourself. + if (p.status === 'dev_block') { + const others = p.devBlockedBy.filter(l => l !== p.data.user.login); + return others.length ? 'Address feedback' : 'Lift your block'; + } if (p.status === 'unmergeable' || p.conflict) return 'Rebase'; if (p.status === 'needs_qa' && !p.qaingLogin && !p.reqaBy.length) return 'Find a QA-er'; if (p.status === 'draft') return 'Finish the draft'; return null; } -/** Your move on someone else's pull; null = not your job right now. */ +/** Your move on someone else's pull; null = not your job right now. The QA + * obligations are NOT gated on status === 'needs_qa': QA runs in parallel with + * CR on this board (see Review.tsx's qaPool), so a push that invalidates both + * your QA and someone's CR at once lands the pull at needs_recr while still + * owing you a re-QA — and rowNote/actionState already surface it, so "Yours to + * do" must too, or the card shows "Re-QA" while the lane silently drops it. */ export function reviewerMove(p: DerivedPull, me: string): string | null { if (p.data.user.login === me) return null; - if (p.status === 'needs_recr' && p.recrBy.includes(me)) return 'Re-stamp'; - if (p.status === 'needs_qa' && p.qaingLogin === me) return 'Finish QA'; - if (p.status === 'needs_qa' && p.reqaBy.includes(me)) return 'Re-QA'; + if (p.recrBy.includes(me)) return 'Re-stamp'; + if (p.qaingLogin === me) return 'Finish QA'; + if (p.reqaBy.includes(me)) return 'Re-QA'; return null; } @@ -40,7 +52,11 @@ export function reviewerMove(p: DerivedPull, me: string): string | null { export function alertMove(p: DerivedPull, me: string): string | null { if (p.data.user.login === me) { const v = authorMove(p); - return v && v !== 'Find a QA-er' && v !== 'Finish the draft' ? v : null; + // 'Lift your block' is a block you put on yourself — a standing choice, + // not an event that landed on you, so it earns no desktop nudge (same as + // 'Find a QA-er' / 'Finish the draft') + const standing = v === 'Find a QA-er' || v === 'Finish the draft' || v === 'Lift your block'; + return v && !standing ? v : null; } const v = reviewerMove(p, me); return v === 'Re-stamp' || v === 'Re-QA' ? v : null; diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 83837934..e5d828b9 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -241,6 +241,7 @@ export function Review({ 'Merge it', 'Fix CI', 'Address feedback', + 'Lift your block', 'Rebase', 'Find a QA-er', ]; @@ -325,10 +326,12 @@ export function Review({ !p.conflict && !['draft', 'dev_block'].includes(p.status) && // your in-flight QA and owed re-QAs live in "Yours to do"; a QA - // stamp you already gave lives in the "QA'd by you" fold + // stamp you already gave lives in the "QA'd by you" fold. The re-QA + // exclusion is status-agnostic to match reviewerMove — a re-QA owed + // on a needs_recr pull is still yours to do, not a generic lane slot p.qaingLogin !== me && !p.qaBy.includes(me) && - !(p.status === 'needs_qa' && p.reqaBy.includes(me)) + !p.reqaBy.includes(me) ); const qaSort = (list: DerivedPull[]) => [...list].sort( @@ -611,7 +614,7 @@ export function Review({ Date: Mon, 20 Jul 2026 23:18:23 -0700 Subject: [PATCH 208/381] Stop nudges crediting self-review, bots, and stale copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sweep of every toast's firing condition against its wording turned up several where the two diverged: Self-review credit. The board counts a stamp the author put on their own PR (the backend parses any CR/QA comment tag, author included). That let stamp-landed / milestone congratulate you for "reviewing" your own PR, pr-first-review say "you is on it", and the leaderboard rank you for it. readSignals now excludes your own pulls from the "stamp landed" set, pr-first-review's reviewed edge and body require a non-author reviewer, and reviewerRanks never credits a pull's own author. (Merge/status semantics are untouched — this is only about who gets review credit in the gamification layer.) Bots in the pick. start-here called dealOne without the deprioritize option Review's Deal-me-one uses, so a stale dependabot XS bump could be announced as "the single best pull to review next". It now deprioritizes bots the same way; quick-wins and your-turn also skip bot-authored pulls. Stale copy. The claim-stale nag said "It's been a couple hours" even when the viewer set the claim-warn threshold to 30 minutes. The copy now interpolates the real threshold. And climbing said "You passed X" naming whoever sits one rank below now — which, with dense-rank ties, can be someone you were already ahead of; the baseline doesn't carry the prior ranking to know who you actually overtook, so it now celebrates the climb without naming a peer. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/cheers.test.ts | 22 ++++++- frontend-v2/src/model/cheers.ts | 70 +++++++++++++++++++---- frontend-v2/src/model/leaderboard.test.ts | 30 ++++++++-- frontend-v2/src/model/leaderboard.ts | 12 +++- 4 files changed, 113 insertions(+), 21 deletions(-) diff --git a/frontend-v2/src/model/cheers.test.ts b/frontend-v2/src/model/cheers.test.ts index 428377c8..691dd86b 100644 --- a/frontend-v2/src/model/cheers.test.ts +++ b/frontend-v2/src/model/cheers.test.ts @@ -78,6 +78,7 @@ function sig(o: Partial & { queue?: number; pulls?: DerivedPull[] } = { myCount: o.myCount ?? 0, peerBelow: o.peerBelow ?? null, staleClaims: o.staleClaims ?? new Map(), + staleClaimAfter: o.staleClaimAfter ?? 'a couple hours', requestedOfMe: o.requestedOfMe ?? new Map(), }; } @@ -379,10 +380,12 @@ describe('diffCheers — leaderboard', () => { expect(toasts.some(t => t.icon === '🏆')).toBe(false); }); - it('fires climbing when your rank improves, naming the peer now behind you', () => { + it('fires climbing when your rank improves', () => { const base = primed(sig({ myRank: 3, myCount: 2 })); const { toasts } = diffCheers(sig({ myRank: 2, myCount: 3, peerBelow: 'bob' }), 'me', base); - expect(toasts.some(t => t.icon === '📈' && t.title === 'You passed bob')).toBe(true); + // no named peer: dense-rank ties make "who you passed" unreliable, so the + // copy celebrates the climb without asserting a specific person + expect(toasts.some(t => t.icon === '📈' && t.title === "You're climbing")).toBe(true); }); it('does not fire climbing without an identifiable peer', () => { @@ -536,6 +539,21 @@ describe('diffCheers — stale claim', () => { expect(toasts.some(t => t.dedupeKey?.startsWith('claimstale:'))).toBe(false); expect(next.staleClaimsSeen.has(pullKey(p.data))).toBe(true); }); + + it('says the real threshold, not a hardcoded "a couple hours"', () => { + const p = pull('org/a', 9); + const stale = new Map([[pullKey(p.data), p]]); + const base = primed(sig()); + // viewer set claim-warn to 30 minutes → the copy must not say "hours" + const { toasts } = diffCheers( + sig({ staleClaims: stale, pulls: [p], staleClaimAfter: '30 minutes' }), + 'me', + base + ); + const body = toasts.find(t => t.icon === '✋')?.body ?? ''; + expect(body).toContain('30 minutes'); + expect(body).not.toContain('hours'); + }); }); describe('diffCheers — review requested', () => { diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index f6c0dca5..91479661 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -8,6 +8,14 @@ import { buildReviewerPools, turnFor } from './rotation'; import { crSort } from './sort'; import type { DerivedPull } from './status'; import type { Toast } from './toast'; +import { isBotLogin } from './visibility'; + +/** The suffix bot check ('…[bot]') is enough in the cheers layer — like + * status.ts's engagedNoStamp, this module has no reason to depend on + * config.json's bots list, and a non-suffixed bot slipping through just costs + * one soft nudge, never a wrong review decision. */ +const NO_EXTRA_BOTS: ReadonlySet = new Set(); +const isBot = (p: DerivedPull) => isBotLogin(p.data.user.login, NO_EXTRA_BOTS); /** * "Cheers": the gamification layer. A pure, edge-triggered evaluator that turns @@ -187,6 +195,16 @@ export interface CheerInput { ready?: boolean; } +/** A duration in ms as the loose phrase the claim-stale nudge shows: "30 + * minutes", "an hour", "2 hours" — matching whatever the viewer set claimWarn + * to, so the copy never says "a couple hours" for a 30-minute threshold. */ +function durationPhrase(ms: number): string { + const mins = Math.round(ms / 60_000); + if (mins < 60) return `${mins} minutes`; + const hours = Math.round(mins / 60); + return hours === 1 ? 'an hour' : `${hours} hours`; +} + /** A claim you're sitting on stops absolving other reviewers at 2h (see * model/actions STALE_CLAIM_SECS); that's the moment worth a nudge to either * finish it or hand it back. Kept here as a self-contained duplicate, same as @@ -306,8 +324,11 @@ export interface Signals { myCount: number; /** the login one rank behind you, for the "climbing" toast */ peerBelow: string | null; - /** pulls you hold a stale (2h+), still-unreviewed claim on */ + /** pulls you hold a stale (past the claim-warn threshold), still-unreviewed + * claim on */ staleClaims: Map; + /** the claim-warn threshold as loose words ("an hour"), for the nudge copy */ + staleClaimAfter: string; /** pulls GitHub has asked YOU to review (and you haven't yet), for the * review-requested toast — the most direct "review this" the board carries */ requestedOfMe: Map; @@ -328,13 +349,17 @@ export function readSignals(input: CheerInput): Signals { for (const p of pulls) { const key = pullKey(p.data); byKey.set(key, p); - const mine = hasStamp(p, me); + // a stamp on your OWN pull isn't review work you did — don't let a + // self-tag fire "you reviewed this" (stamp-landed / milestone) + const mine = hasStamp(p, me) && p.data.user.login !== me; if (mine) stamped.add(key); const st = actionState(p, me); if (st === 'review') review++; else if (st === 'qa') qa++; else if (st === 'restamp') restampKeys.add(key); - if (!mine && !claims[key] && turnFor(p, pools, pulls) === me) turns.set(key, p); + // a starved BOT pull shouldn't tap you with "your turn — you're the best + // fit"; bots are handled on their own low-priority cadence, not the rotation + if (!mine && !isBot(p) && !claims[key] && turnFor(p, pools, pulls) === me) turns.set(key, p); } const reviewableUnclaimed = pulls.filter( @@ -342,11 +367,22 @@ export function readSignals(input: CheerInput): Signals { ); const quickWinCandidates = crSort( - reviewableUnclaimed.filter(p => p.sizeKnown && (p.weight === 'XS' || p.weight === 'S')) + reviewableUnclaimed.filter( + p => !isBot(p) && p.sizeKnown && (p.weight === 'XS' || p.weight === 'S') + ) ); const quickWinPull = quickWinCandidates[0] ?? null; - const bestStart = dealOne(reviewableUnclaimed, { me, pulls, claims, passed: new Set() }); + // deprioritize bots exactly as Review's Deal-me-one does, so start-here never + // calls a dependabot bump "the single best pull to review next" — it's only + // the best when nothing human is left + const bestStart = dealOne(reviewableUnclaimed, { + me, + pulls, + claims, + passed: new Set(), + deprioritize: isBot, + }); const startReason = bestStart ? startHereReason(bestStart, pulls, me) : ''; // reciprocity: who has stamped one of your own pulls, and do they have an @@ -376,7 +412,9 @@ export function readSignals(input: CheerInput): Signals { for (const p of myPulls) { authorPrs.set(pullKey(p.data), { green: p.status === 'ready', - reviewed: p.crBy.length > 0, + // "someone picked up your PR" means someone ELSE — a self-CR stamp + // isn't a reviewer showing up + reviewed: p.crBy.some(l => l !== me), conflict: p.conflict, starved: p.starved, ciRed: p.status === 'ci_red', @@ -388,9 +426,12 @@ export function readSignals(input: CheerInput): Signals { p => p.status === 'needs_cr' || p.status === 'needs_recr' || p.status === 'needs_qa' ).length; - // claims of yours gone stale (past 2h) that you still haven't stamped — the - // nudge to either finish the review or release it back to the pool + // claims of yours gone stale that you still haven't stamped — the nudge to + // either finish the review or release it back to the pool. The threshold is + // the viewer's claim-warn setting (30m–4h), so the copy has to say the real + // number, not a hardcoded "a couple hours" const warnMs = input.claimWarnMs ?? STALE_CLAIM_MS; + const staleClaimAfter = durationPhrase(warnMs); const staleClaims = new Map(); for (const p of pulls) { const key = pullKey(p.data); @@ -449,6 +490,7 @@ export function readSignals(input: CheerInput): Signals { myCount, peerBelow, staleClaims, + staleClaimAfter, requestedOfMe, }; } @@ -847,7 +889,7 @@ export function diffCheers( tone: 'nag', icon: '✋', title: 'You claimed this — still unreviewed', - body: "It's been a couple hours. Review it, or release it for someone else.", + body: `It's been ${sig.staleClaimAfter}. Review it, or release it for someone else.`, pull: pullRef(p), dedupeKey: `claimstale:${key}`, }, @@ -934,8 +976,12 @@ export function diffCheers( { tone: 'reward', icon: '📈', - title: `You passed ${sig.peerBelow}`, - body: `#${sig.myRank} reviewer on the board.`, + // don't name who you "passed": with dense-rank ties the login one + // rank below you now may be someone you were already ahead of, not + // the one you actually overtook this tick — and the baseline + // doesn't carry the prior full ranking to tell them apart + title: "You're climbing", + body: `Now #${sig.myRank} reviewer on the board.`, celebrate: true, dedupeKey: `rank:${sig.myRank}`, }, @@ -983,7 +1029,7 @@ export function diffCheers( tone: 'info', icon: '👀', title: 'Someone picked up your PR', - body: `${p.crBy[0] ?? 'A reviewer'} is on it.`, + body: `${p.crBy.find(l => l !== p.data.user.login) ?? 'A reviewer'} is on it.`, pull: pullRef(p), dedupeKey: `firstrev:${key}`, }, diff --git a/frontend-v2/src/model/leaderboard.test.ts b/frontend-v2/src/model/leaderboard.test.ts index c42aa542..7f4a7e1a 100644 --- a/frontend-v2/src/model/leaderboard.test.ts +++ b/frontend-v2/src/model/leaderboard.test.ts @@ -3,10 +3,16 @@ import type { PullData } from '../types'; import { reviewerRanks } from './leaderboard'; import type { DerivedPull } from './status'; -/** An open DerivedPull carrying only the fields reviewerRanks reads. */ -function open(repo: string, number: number, o: { crBy?: string[]; qaBy?: string[] } = {}) { +/** An open DerivedPull carrying only the fields reviewerRanks reads. author + * defaults to a sentinel that never appears in crBy/qaBy, so existing cases are + * unaffected by the self-stamp exclusion. */ +function open( + repo: string, + number: number, + o: { crBy?: string[]; qaBy?: string[]; author?: string } = {} +) { return { - data: { repo, number }, + data: { repo, number, user: { login: o.author ?? '__author__' } }, crBy: o.crBy ?? [], qaBy: o.qaBy ?? [], } as unknown as DerivedPull; @@ -14,11 +20,16 @@ function open(repo: string, number: number, o: { crBy?: string[]; qaBy?: string[ /** A closed PullData carrying only the fields reviewerRanks reads — allCR/ * allQA logins count whether active or stale. */ -function closed(repo: string, number: number, o: { cr?: string[]; qa?: string[] } = {}): PullData { +function closed( + repo: string, + number: number, + o: { cr?: string[]; qa?: string[]; author?: string } = {} +): PullData { const sig = (login: string) => ({ data: { user: { login } } }); return { repo, number, + user: { login: o.author ?? '__author__' }, status: { allCR: (o.cr ?? []).map(sig), allQA: (o.qa ?? []).map(sig) }, } as unknown as PullData; } @@ -65,4 +76,15 @@ describe('reviewerRanks', () => { const p = open('org/a', 1, { crBy: [''] }); expect(reviewerRanks([p], []).size).toBe(0); }); + + it('never credits a pull author for stamping their own PR', () => { + // a self-tagged CR/QA is not review work — open and closed alike + const openSelf = open('org/a', 1, { author: 'alice', crBy: ['alice', 'bob'] }); + const closedSelf = closed('org/a', 2, { author: 'alice', qa: ['alice'] }); + const ranks = reviewerRanks([openSelf], [closedSelf]); + // alice authored both and only self-stamped → no credit at all + expect(ranks.get('alice')).toBeUndefined(); + // bob's real review of alice's PR still counts + expect(ranks.get('bob')).toEqual({ count: 1, rank: 1 }); + }); }); diff --git a/frontend-v2/src/model/leaderboard.ts b/frontend-v2/src/model/leaderboard.ts index f0e8ca66..5752cf19 100644 --- a/frontend-v2/src/model/leaderboard.ts +++ b/frontend-v2/src/model/leaderboard.ts @@ -32,14 +32,20 @@ export function reviewerRanks(open: DerivedPull[], closed: PullData[]): Map Date: Mon, 20 Jul 2026 23:18:35 -0700 Subject: [PATCH 209/381] Fix mobile title crush in closed rows and stat cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a phone, a closed PR's title wrapped one character per line: the row's right cluster (repo ref + timestamp, flex-none whitespace-nowrap) wouldn't shrink, starving the flex-1 title to ~0px, and break-words then stacked it vertically. ClosedRow now puts the title on its own line with a muted repo · age line beneath, both inside one min-w-0 column, so nothing competes with the title for width at any viewport. PersonCell (shared by the leaderboard, starvation, author-load, and reciprocity stat cards) had the same shape: an unbounded flex-none login span that a long GitHub handle could push into page-level horizontal scroll. It now uses min-w-0 flex-1 truncate, matching every other avatar+name lead in the app. Also rename the "recently shipped" fold to "recently closed": it lists closed-without-merge PRs too, so the collapsed count could read as N merges. The per-row badges already say merged vs closed; the label now matches. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/ClosedRow.tsx | 26 ++++++++++++++---------- frontend-v2/src/views/MyWork.tsx | 2 +- frontend-v2/src/views/stats/parts.tsx | 5 ++++- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index 66879d0d..4638b863 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -5,11 +5,13 @@ import { Avatar, PullTitleLink, RepoRef } from './bits'; import { ClosedBadgeTrigger } from './StatePopover'; /** - * Full-width row: the badge, avatar, and right cluster are all flex-none, so - * inside a narrow column the title gets ~0px and wraps one character per - * line. Columns must use a compact two-zone card instead (Classic's - * ClosedCard). Memoized like Row: closed rows live in folds that re-render - * with every publish, but a closed pull's data never changes again. + * Full-width receipt row. The badge + avatar are flex-none; everything else + * lives in ONE flexible min-w-0 column — title on top, a muted repo · age line + * under it — so nothing competes with the title for width. The old layout kept + * the repo ref + age in a flex-none whitespace-nowrap cluster on the right, + * which on a phone starved the title to ~0px and stacked it one char per line. + * Memoized like Row: closed rows live in folds that re-render with every + * publish, but a closed pull's data never changes again. */ export const ClosedRow = memo(function ClosedRow({ pull, @@ -37,12 +39,14 @@ export const ClosedRow = memo(function ClosedRow({ - - - - - - {ago(closedAt)} ago + + + + + + + {ago(closedAt)} ago +
); diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index 8bc56040..b34cca86 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -69,7 +69,7 @@ export function MyWork({ - + {/* min-w-0 + truncate, not flex-none: a long GitHub login (up to 39 + chars) must yield to the bar/trail instead of overflowing the card + into page-level horizontal scroll on a phone */} + {mine ? {login} : login} {mine && you} From 19d5e46c0d4ff13974fa4ba7ee28324de450d647 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 10:11:24 -0700 Subject: [PATCH 210/381] Make the shipped catch-up toast configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings had a per-kind switch for all 19 board-diff cheers, but the "Shipped while you were away" catch-up — fired from the app's extras path on load, not the board diff — had none. It always popped on every session and ignored even the master Cheers switch, so a user who turned cheers off still got it and had no way to silence it. Give it a kind ('shipped') and catalog it in EXTRA_TOASTS (kept out of CHEER_CATALOG/PRIORITY_ORDER since it isn't a diff kind, so the 1:1 catalog invariant holds). Settings now renders CONFIGURABLE_TOASTS — the diff cheers plus the extras — so it gets a switch and a blurb in the nudge group like everything else. The extras effect now respects the master Cheers toggle and the per-kind mute, marking a muted extra fired anyway so it can't replay a stale catch-up if unmuted mid-session. Audited the rest: the 19 cheer kinds are all covered, SAMPLE_CHEERS is the dev-only preview handle, and desktop notifications keep their own notify/notifySound master switches. This was the one user-facing toast with no off switch. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Settings.tsx | 4 ++-- frontend-v2/src/model/cheers.test.ts | 17 +++++++++++++++++ frontend-v2/src/model/cheers.ts | 23 +++++++++++++++++++++++ frontend-v2/src/model/shipped.test.ts | 5 +++++ frontend-v2/src/model/shipped.ts | 2 ++ frontend-v2/src/model/toast.ts | 4 ++++ frontend-v2/src/toasts.tsx | 16 +++++++++------- 7 files changed, 62 insertions(+), 9 deletions(-) diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index e2c8ab5f..d5bfdb58 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -8,7 +8,7 @@ import { testNotification, unlockSound, } from '../notifications'; -import { CHEER_CATALOG, type CheerGroup } from '../model/cheers'; +import { type CheerGroup, CONFIGURABLE_TOASTS } from '../model/cheers'; import { clearSnoozes, markAllSeen, refreshAll, usePulldasher } from '../store'; import { addCodeRegion, @@ -81,7 +81,7 @@ function CheerToggles({ disabled }: { disabled: boolean }) { {title} - {CHEER_CATALOG.filter(c => c.group === group).map(c => ( + {CONFIGURABLE_TOASTS.filter(c => c.group === group).map(c => (
{c.label} diff --git a/frontend-v2/src/model/cheers.test.ts b/frontend-v2/src/model/cheers.test.ts index 691dd86b..0ba576a8 100644 --- a/frontend-v2/src/model/cheers.test.ts +++ b/frontend-v2/src/model/cheers.test.ts @@ -4,12 +4,14 @@ import { type AuthorPrState, CHEER_CATALOG, type CheerBaseline, + CONFIGURABLE_TOASTS, diffCheers, EMPTY_BASELINE, evaluateCheers, PRIORITY_ORDER, reviveBaseline, serializeBaseline, + SHIPPED_TOAST_KIND, type Signals, startHereReason, type ToastKind, @@ -720,6 +722,21 @@ describe('cheer catalog', () => { expect(new Set(catalogKinds).size).toBe(catalogKinds.length); expect(new Set(catalogKinds)).toEqual(new Set(PRIORITY_ORDER)); }); + + it('makes every user-facing toast configurable, including the extras', () => { + const kinds = CONFIGURABLE_TOASTS.map(c => c.kind); + // no dupes across the diff cheers + the extra load-time toasts + expect(new Set(kinds).size).toBe(kinds.length); + // every diff kind, plus the shipped catch-up that fires off the extras + // path (so it's not in PRIORITY_ORDER but still gets a Settings switch) + expect(kinds).toEqual(expect.arrayContaining([...PRIORITY_ORDER])); + expect(kinds).toContain(SHIPPED_TOAST_KIND); + // and every configurable toast carries the label + hint Settings renders + for (const c of CONFIGURABLE_TOASTS) { + expect(c.label.length).toBeGreaterThan(0); + expect(c.hint.length).toBeGreaterThan(0); + } + }); }); describe('diffCheers — muting', () => { diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index 91479661..dad70ad7 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -670,6 +670,29 @@ export const CHEER_CATALOG: { }, ]; +/** Kinds that don't come from diffCheers (so they're outside PRIORITY_ORDER), + * but are still user-facing toasts that deserve a switch in Settings. The + * shipped catch-up is fired from the app's `extras` path on load, not the + * board diff, so it's catalogued here rather than in CHEER_CATALOG. */ +export const SHIPPED_TOAST_KIND = 'shipped'; +export const EXTRA_TOASTS: { + kind: string; + group: CheerGroup; + label: string; + hint: string; +}[] = [ + { + kind: SHIPPED_TOAST_KIND, + group: 'nudge', + label: 'Shipped while away', + hint: 'A recap of PRs that merged since your last visit.', + }, +]; + +/** Everything Settings shows a per-kind toggle for: the board-diff cheers plus + * the extra load-time toasts. mutedCheers stores whichever of these are off. */ +export const CONFIGURABLE_TOASTS = [...CHEER_CATALOG, ...EXTRA_TOASTS]; + const NO_MUTED: ReadonlySet = new Set(); /** diff --git a/frontend-v2/src/model/shipped.test.ts b/frontend-v2/src/model/shipped.test.ts index cdab6c4c..86723a0e 100644 --- a/frontend-v2/src/model/shipped.test.ts +++ b/frontend-v2/src/model/shipped.test.ts @@ -87,6 +87,11 @@ describe('shippedToast', () => { expect(toast?.pull?.number).toBe(2); }); + it('tags the toast with its kind so Settings can mute it', () => { + const toast = shippedToast([dp({ number: 1, author: 'me' })], 'me'); + expect(toast?.kind).toBe('shipped'); + }); + it('names the pull and yours/reviewed for a single relevant pull', () => { const mine = dp({ number: 42, author: 'me' }); const toast = shippedToast([mine], 'me'); diff --git a/frontend-v2/src/model/shipped.ts b/frontend-v2/src/model/shipped.ts index ce89ed3f..1cc2ced4 100644 --- a/frontend-v2/src/model/shipped.ts +++ b/frontend-v2/src/model/shipped.ts @@ -1,5 +1,6 @@ import { closedEpoch } from '../format'; import type { PullData } from '../types'; +import { SHIPPED_TOAST_KIND } from './cheers'; import type { Toast } from './toast'; /** @@ -65,6 +66,7 @@ export function shippedToast(shipped: PullData[], me: string): Toast | null { .join(' · '); return { tone: 'info', + kind: SHIPPED_TOAST_KIND, icon: '📦', title: n === 1 ? 'Shipped while you were away' : `${n} shipped while you were away`, body: diff --git a/frontend-v2/src/model/toast.ts b/frontend-v2/src/model/toast.ts index 590ea1c9..f0fd8c9b 100644 --- a/frontend-v2/src/model/toast.ts +++ b/frontend-v2/src/model/toast.ts @@ -9,6 +9,10 @@ export type ToastTone = 'reward' | 'nag' | 'info'; */ export interface Toast { tone: ToastTone; + /** which configurable kind this is, for the Settings mute check. Board-diff + * cheers set it from their catalog entry; extras (the shipped catch-up) set + * it too so they can be toggled off like any other toast. */ + kind?: string; /** a single emoji, the toast's face */ icon: string; title: string; diff --git a/frontend-v2/src/toasts.tsx b/frontend-v2/src/toasts.tsx index 7bece37d..8ec93ce4 100644 --- a/frontend-v2/src/toasts.tsx +++ b/frontend-v2/src/toasts.tsx @@ -363,16 +363,18 @@ export function useToasts( // pre-built one-shot toasts from the caller (e.g. the shipped catch-up), // deduped by dedupeKey so the same logical toast never re-fires on a later - // tick — not gated by the cheers setting, since this is informational, not - // gamification. + // tick. Gated by the master cheers switch and each toast's own per-kind mute + // (Settings), same as the board-diff cheers — a muted extra is still marked + // fired so it can't replay a stale catch-up if unmuted later this session. useEffect(() => { - if (!ready) return; + if (!ready || !getSettings().cheers) return; const fresh = extras.filter(t => t.dedupeKey && !firedKeys.current.has(t.dedupeKey)); + if (!fresh.length) return; for (const t of fresh) firedKeys.current.add(t.dedupeKey as string); - if (fresh.length) { - push(fresh); - saveCheerSession(me, baseline.current, firedKeys.current); - } + const muted = new Set(getSettings().mutedCheers); + const show = fresh.filter(t => !(t.kind && muted.has(t.kind))); + if (show.length) push(show); + saveCheerSession(me, baseline.current, firedKeys.current); }, [ready, extras, push, me]); // Dev-only preview handle: with the dummy backend the board is static, so From 828343c893fdb5f70c396502adf53c3da8e948a4 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 10:17:50 -0700 Subject: [PATCH 211/381] Name each cheer toggle after the toast it controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-kind switches were named by concept, not by what the toast actually says, so a toast on screen didn't map to an obvious switch: you saw "Green — ship it" but the toggle said "Your PR is green", or "You're climbing" under a switch called "Climbing the ranks". Users had to guess. Relabel every toggle to its toast's own headline. Where the headline leads with a count or a name ("5 stamps in view", "Return the favor to alice"), the label is the stable phrase ("Top of the board", "Return the favor"). stamp-landed is the one exception — its headline rotates ("Nice one." / "Keep 'em coming." / "Clean.") so the label names the event and the hint now quotes the phrases, which is the exact mapping that prompted this. No behavior change: labels and hints only. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/cheers.ts | 41 +++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index dad70ad7..de40f3df 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -556,17 +556,23 @@ export const CHEER_CATALOG: { label: string; hint: string; }[] = [ + // Each label matches the toast's own headline so a toast you see maps to + // an obvious switch — no guessing. Where the headline leads with a count or + // a name ("5 stamps in view", "Return the favor to alice"), the label is + // its stable phrase. stamp-landed is the one exception: its headline rotates + // ("Nice one." / "Keep 'em coming." / "Clean."), so the label names the + // event and the hint quotes the phrases. // Rewards — the good news { kind: 'stamp-landed', group: 'reward', label: 'Review landed', - hint: 'A CR or QA stamp of yours lands.', + hint: 'The “Nice one.” / “Keep ’em coming.” cheer when a CR or QA stamp of yours lands.', }, { kind: 'milestone', group: 'reward', - label: 'Review streak', + label: 'Reviews this sitting', hint: 'You pass a round number of reviews this sitting.', }, { @@ -584,19 +590,19 @@ export const CHEER_CATALOG: { { kind: 'top-of-board', group: 'reward', - label: 'Top reviewer', + label: 'Top of the board', hint: 'You reach the top of the board’s leaderboard.', }, { kind: 'climbing', group: 'reward', - label: 'Climbing the ranks', - hint: 'You pass someone on the leaderboard.', + label: 'You’re climbing', + hint: 'You move up the leaderboard.', }, { kind: 'pr-green', group: 'reward', - label: 'Your PR is green', + label: 'Green — ship it', hint: 'A PR of yours clears CR and QA — ready to ship.', }, // Nudges — what to review next @@ -609,7 +615,7 @@ export const CHEER_CATALOG: { { kind: 'quick-wins', group: 'nudge', - label: 'Quick wins', + label: 'Quick reviews on the board', hint: 'A pile of small, unclaimed reviews worth a pass.', }, { @@ -621,7 +627,7 @@ export const CHEER_CATALOG: { { kind: 'your-turn', group: 'nudge', - label: 'Your turn', + label: 'Your turn to review', hint: 'You’re the best-matched reviewer for a starved, unclaimed PR.', }, { @@ -639,33 +645,38 @@ export const CHEER_CATALOG: { { kind: 'claim-stale', group: 'nudge', - label: 'Stale claim', + label: 'Claimed but still unreviewed', hint: 'A review you claimed has sat unreviewed too long.', }, // Your PRs — author-side alerts { kind: 'pr-first-review', group: 'author', - label: 'Picked up', + label: 'Someone picked up your PR', hint: 'A reviewer started on your PR.', }, - { kind: 'pr-conflicts', group: 'author', label: 'Conflicts', hint: 'Your PR needs a rebase.' }, + { + kind: 'pr-conflicts', + group: 'author', + label: 'Conflicts on your PR', + hint: 'Your PR needs a rebase.', + }, { kind: 'pr-ci-red', group: 'author', - label: 'CI broke', + label: 'CI broke on your PR', hint: 'A required check went red on your PR.', }, { kind: 'pr-changes', group: 'author', - label: 'Changes requested', + label: 'Changes requested on your PR', hint: 'A reviewer wants edits on your PR.', }, { kind: 'pr-starving', group: 'author', - label: 'Waiting too long', + label: 'Waiting for review', hint: 'Your PR has gone a while with no review.', }, ]; @@ -684,7 +695,7 @@ export const EXTRA_TOASTS: { { kind: SHIPPED_TOAST_KIND, group: 'nudge', - label: 'Shipped while away', + label: 'Shipped while you were away', hint: 'A recap of PRs that merged since your last visit.', }, ]; From 2e4994e51575c67eec6c986a3a13610bf2be5a20 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 11:12:58 -0700 Subject: [PATCH 212/381] Bring back the v1 board side-by-side at /v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 was retired in 0194ae9 (frontend/ deleted, its webpack/babel/less/ chakra toolchain stripped from package.json, v2 moved to the root). We want the classic board back alongside v2, not instead of it, so this restores v1 without disturbing v2's place at /. - Restore frontend/ verbatim from the pre-retire tree (0194ae9^): all 43 v1 source files, its webpack configs, and the dummy fixture. - Re-add the v1 build toolchain to package.json (chakra/emotion/ framer-motion/fortawesome/react-17 + webpack/babel/less/ts-loader). postinstall builds both frontends again (v1 webpack, then v2 vite). Pin @types/lodash to 4.14.181 via overrides — the current release uses TS-5 syntax the pinned typescript@4.1 can't parse. - Serve v1 at /v1: app.js mounts frontend/dist there ahead of the '/' v2 mount, the auth gate covers /v1 like the root, and v1's webpack publicPath is /v1/ so its asset URLs resolve. Both boards share the same /token + socket API. v1's app code is otherwise unchanged. - Cross-links: restore the "v1 board" link in v2's header (now → /v1), and repoint v1's existing "v2" link from the dead /v2/ to /. Co-Authored-By: Claude Opus 4.8 --- app.js | 5 + frontend-v2/src/app.tsx | 7 + frontend/dummy-pulls.json | 8061 ++++++ frontend/index.html | 15 + frontend/src/backend/dummy-pulls.ts | 59 + frontend/src/backend/pull-socket.ts | 58 + frontend/src/backend/socket.ts | 58 + frontend/src/closed-pulls.tsx | 41 + frontend/src/column.tsx | 49 + frontend/src/filter-menu.tsx | 157 + frontend/src/index.tsx | 19 + frontend/src/leader-list.tsx | 112 + frontend/src/navbar.tsx | 277 + frontend/src/notifications.tsx | 111 + frontend/src/page-context.ts | 29 + frontend/src/pull-card/age.tsx | 28 + frontend/src/pull-card/avatar.tsx | 38 + frontend/src/pull-card/commit-statuses.tsx | 146 + frontend/src/pull-card/copy-branch.tsx | 34 + frontend/src/pull-card/flags.tsx | 135 + frontend/src/pull-card/index.tsx | 221 + frontend/src/pull-card/participants.tsx | 36 + frontend/src/pull-card/refresh.tsx | 59 + frontend/src/pull-card/signatures.tsx | 192 + frontend/src/pull.ts | 270 + .../src/pulldasher/filtered-pulls-state.ts | 44 + frontend/src/pulldasher/index.tsx | 109 + frontend/src/pulldasher/notifications.tsx | 28 + frontend/src/pulldasher/pulls-context.tsx | 86 + frontend/src/pulldasher/pulls-state.tsx | 47 + frontend/src/pulldasher/sort.ts | 69 + frontend/src/theme.tsx | 226 + frontend/src/theme/base.less | 34 + frontend/src/theme/day_theme.less | 184 + frontend/src/theme/night_theme.less | 193 + frontend/src/types.ts | 130 + frontend/src/use-url-state.ts | 118 + frontend/src/utils.ts | 95 + frontend/test/named-pulls.ts | 532 + frontend/test/pull-card-demo.tsx | 86 + frontend/test/pull-data-parts.ts | 145 + frontend/tsconfig.json | 14 + frontend/webpack-define-read-file.js | 13 + frontend/webpack.dev.config.js | 106 + frontend/webpack.prod.config.js | 4 + lib/authentication.js | 5 + package-lock.json | 20721 +++++++++++++--- package.json | 42 +- 48 files changed, 29502 insertions(+), 3746 deletions(-) create mode 100644 frontend/dummy-pulls.json create mode 100644 frontend/index.html create mode 100644 frontend/src/backend/dummy-pulls.ts create mode 100644 frontend/src/backend/pull-socket.ts create mode 100644 frontend/src/backend/socket.ts create mode 100644 frontend/src/closed-pulls.tsx create mode 100644 frontend/src/column.tsx create mode 100644 frontend/src/filter-menu.tsx create mode 100644 frontend/src/index.tsx create mode 100644 frontend/src/leader-list.tsx create mode 100644 frontend/src/navbar.tsx create mode 100644 frontend/src/notifications.tsx create mode 100644 frontend/src/page-context.ts create mode 100644 frontend/src/pull-card/age.tsx create mode 100644 frontend/src/pull-card/avatar.tsx create mode 100644 frontend/src/pull-card/commit-statuses.tsx create mode 100644 frontend/src/pull-card/copy-branch.tsx create mode 100644 frontend/src/pull-card/flags.tsx create mode 100644 frontend/src/pull-card/index.tsx create mode 100644 frontend/src/pull-card/participants.tsx create mode 100644 frontend/src/pull-card/refresh.tsx create mode 100644 frontend/src/pull-card/signatures.tsx create mode 100644 frontend/src/pull.ts create mode 100644 frontend/src/pulldasher/filtered-pulls-state.ts create mode 100644 frontend/src/pulldasher/index.tsx create mode 100644 frontend/src/pulldasher/notifications.tsx create mode 100644 frontend/src/pulldasher/pulls-context.tsx create mode 100644 frontend/src/pulldasher/pulls-state.tsx create mode 100644 frontend/src/pulldasher/sort.ts create mode 100644 frontend/src/theme.tsx create mode 100644 frontend/src/theme/base.less create mode 100644 frontend/src/theme/day_theme.less create mode 100644 frontend/src/theme/night_theme.less create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/use-url-state.ts create mode 100644 frontend/src/utils.ts create mode 100644 frontend/test/named-pulls.ts create mode 100644 frontend/test/pull-card-demo.tsx create mode 100644 frontend/test/pull-data-parts.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/webpack-define-read-file.js create mode 100644 frontend/webpack.dev.config.js create mode 100644 frontend/webpack.prod.config.js diff --git a/app.js b/app.js index 263172d1..573c00c8 100644 --- a/app.js +++ b/app.js @@ -56,6 +56,11 @@ app.use(function (req, res, next) { * Routes */ authManager.setupRoutes(app); +// v2 is the primary board at the root; the legacy v1 board runs side-by-side +// under /v1 (its assets are built with a matching /v1/ publicPath). Both share +// the same /token + socket.io API below. The /v1 mount is more specific, so it +// must precede the '/' catch-all static mount. +app.use('/v1', express.static(__dirname + '/frontend/dist')); app.use('/', express.static(__dirname + '/frontend-v2/dist')); app.get('/token', mainController.getToken); app.get('/stats-history', statsController.getHistory); diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 967e1ca5..8294447f 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -843,6 +843,13 @@ export function App() { open + + v1 board + + + + + + + + + + + diff --git a/frontend/src/backend/dummy-pulls.ts b/frontend/src/backend/dummy-pulls.ts new file mode 100644 index 00000000..9d6d4ef5 --- /dev/null +++ b/frontend/src/backend/dummy-pulls.ts @@ -0,0 +1,59 @@ +import { Pull } from "../pull"; +import { PullData, Signature, SignatureType, CommentSource } from "../types"; + +const COMMENT_ID = 13371337; + +function addSynthReview(pull: Pull) { + const signed: PullData = { + ...pull, + status: { + ...pull.status, + allQA: [...pull.status.allQA, getFakeSig(pull.number, SignatureType.QA)], + allCR: [...pull.status.allCR, getFakeSig(pull.number, SignatureType.CR)], + }, + }; + return new Pull(signed); +} + +function removeSynthReview(pull: Pull) { + const unsigned: PullData = { + ...pull, + status: { + ...pull.status, + allQA: pull.status.allQA.filter((sig) => !isSynthComment(sig)), + allCR: pull.status.allCR.filter((sig) => !isSynthComment(sig)), + }, + }; + return new Pull(unsigned); +} + +export function toggleSynthReview(pull: Pull) { + const isFaked = pull.status.allQA.find(isSynthComment); + if (isFaked) { + return removeSynthReview(pull); + } else { + return addSynthReview(pull); + } +} + +function getFakeSig(number: number, type: SignatureType) { + return { + data: { + repo: "iFixit/ifixit", + number, + user: { + id: 13455801, + login: "danielbeardsley", + }, + type, + created_at: "1970-11-16T20:06:15.000Z", + active: 1, + comment_id: COMMENT_ID, + source_type: CommentSource.review, + }, + }; +} + +function isSynthComment(sig: Signature) { + return sig.data.comment_id === COMMENT_ID; +} diff --git a/frontend/src/backend/pull-socket.ts b/frontend/src/backend/pull-socket.ts new file mode 100644 index 00000000..1304657f --- /dev/null +++ b/frontend/src/backend/pull-socket.ts @@ -0,0 +1,58 @@ +import { Pull } from "../pull"; +import { getSocket } from "./socket"; +import { PullData, RepoSpec } from "../types"; +import { dummyPulls } from "../utils"; +import { toggleSynthReview } from "./dummy-pulls"; + +type PullUpdater = (pullDatas: PullData[], repoSpecs: RepoSpec[]) => void; +let repoSpecs: RepoSpec[] = []; + +/** + * connects to the backend and calls the callback each time we receive + * pullChange events from the server. + */ +function pullSocket(pullsUpdated: PullUpdater) { + const socket = getSocket(); + socket.on( + "initialize", + function (data: { repos: RepoSpec[]; pulls: PullData[] }) { + repoSpecs = data.repos; + pullsUpdated(data.pulls, repoSpecs); + } + ); + + socket.on("pullChange", function (pull: PullData) { + pullsUpdated([pull], repoSpecs); + }); +} + +function sendRefreshPull(pull: Pull) { + const socket = getSocket(); + socket.emit("refresh", pull.repo, pull.number); +} + +/************** + * When we are faking the backend with a dummy array of pulls, + * we need to fake other backend interactions (like refresh) + */ + +let pullsUpdatedHandler: PullUpdater | null; +function mockPullSocket(pullsUpdated: PullUpdater) { + pullsUpdatedHandler = pullsUpdated; + return pullsUpdated(dummyPulls, repoSpecs); +} + +export function mockRefreshPull(pull: Pull) { + // Pretend the pull is updated from the server-side a bit later. + setTimeout(() => { + if (pullsUpdatedHandler) { + const updatedPull = toggleSynthReview(pull); + pullsUpdatedHandler([updatedPull], []); + } + }, 2000); +} + +export const createPullSocket = dummyPulls.length ? mockPullSocket : pullSocket; +export const refreshPull = dummyPulls.length + ? mockRefreshPull + : sendRefreshPull; diff --git a/frontend/src/backend/socket.ts b/frontend/src/backend/socket.ts new file mode 100644 index 00000000..a256bb4c --- /dev/null +++ b/frontend/src/backend/socket.ts @@ -0,0 +1,58 @@ +import { getPageContext } from "../page-context"; +import { io, Socket } from "socket.io-client"; +import { useState, useEffect } from "react"; +import { hasDummyPulls } from "../utils"; + +type SocketIO = Socket; + +let socket: SocketIO; + +function createSocket(): SocketIO { + socket = io(); + + socket.on("connect", function () { + getPageContext().then((details) => { + socket.emit("authenticate", details.socketToken); + }); + }); + return socket; +} + +export function getSocket() { + return (socket = socket || createSocket()); +} + +export enum ConnectionState { + disconnected = "disconnected", + connected = "connected", + connecting = "connecting", + error = "error", +} + +export function useConnectionState(): ConnectionState { + const [state, setState] = useState( + ConnectionState.connecting + ); + if (hasDummyPulls()) { + return ConnectionState.connected; + } + useEffect(() => listenForConnectionEvents(setState), [getSocket()]); + return state; +} + +function listenForConnectionEvents(setState: (state: ConnectionState) => void) { + const onConnect = () => setState(ConnectionState.connected); + const onDisconnect = () => setState(ConnectionState.disconnected); + const onReconnectAttempt = () => setState(ConnectionState.connecting); + const socket = getSocket(); + socket.on("connect", onConnect); + socket.on("disconnect", onDisconnect); + socket.io.on("reconnect_attempt", onReconnectAttempt); + socket.io.engine.on("close", onDisconnect); + return () => { + socket.off("connect", onConnect); + socket.off("disconnect", onDisconnect); + socket.io.off("reconnect_attempt", onReconnectAttempt); + socket.io.engine.off("close", onDisconnect); + }; +} diff --git a/frontend/src/closed-pulls.tsx b/frontend/src/closed-pulls.tsx new file mode 100644 index 00000000..2a2665f1 --- /dev/null +++ b/frontend/src/closed-pulls.tsx @@ -0,0 +1,41 @@ +import { useFilteredPulls } from "./pulldasher/pulls-context"; +import { useStyleConfig, Flex, Spacer, Box } from "@chakra-ui/react"; +import { ClosedPullCard } from "./pull-card"; +import { closedAtCompare } from "./pulldasher/sort"; +import { useMemo } from "react"; + +export function ClosedPulls({ onClickClose }: { onClickClose: () => void }) { + const filteredPulls = useFilteredPulls(); + const closedPulls = useMemo(() => { + const pulls = filteredPulls.filter((pull) => pull.closed_at); + pulls.sort(closedAtCompare); + return pulls; + }, [filteredPulls]); + + const styles = useStyleConfig("Column", { variant: "closed" }); + return ( + + + + Recently Closed Pulls + + + + {closedPulls.length} + + + {closedPulls.map((pull) => ( + + ))} + + ); +} diff --git a/frontend/src/column.tsx b/frontend/src/column.tsx new file mode 100644 index 00000000..9b95b095 --- /dev/null +++ b/frontend/src/column.tsx @@ -0,0 +1,49 @@ +import { Pull } from "./pull"; +import { PullCard } from "./pull-card"; +import { useBoolUrlState } from "./use-url-state"; +import { useFilteredOpenPulls } from "./pulldasher/pulls-context"; +import { Box, Flex, Spacer, useStyleConfig } from "@chakra-ui/react"; + +interface ColumnProps { + variant?: string; + pulls: Pull[]; + title: string; + id: string; + showLinesChanged?: boolean; +} + +export function Column(props: ColumnProps) { + const pullsToShow = useFilteredOpenPulls(); + const [open, toggleOpen] = useBoolUrlState(props.id, true); + const styles = useStyleConfig("Column", { variant: props.variant }); + return ( + + + + {props.title} + + + + {countPulls(props.pulls, pullsToShow)} + + + + {props.pulls.map((pull) => ( + + ))} + + + ); +} + +function countPulls(pulls: Pull[], pullsToShow: Set): number { + return pulls.reduce( + (count, pull) => count + (pullsToShow.has(pull) ? 1 : 0), + 0 + ); +} diff --git a/frontend/src/filter-menu.tsx b/frontend/src/filter-menu.tsx new file mode 100644 index 00000000..53d6bf67 --- /dev/null +++ b/frontend/src/filter-menu.tsx @@ -0,0 +1,157 @@ +import { useAllOpenPulls, useSetFilter } from "./pulldasher/pulls-context"; +import { useArrayUrlState } from "./use-url-state"; +import { Pull } from "./pull"; +import { + useConst, + Button, + Menu, + MenuButton, + MenuList, + MenuDivider, + MenuOptionGroup, + MenuItemOption, + chakra, +} from "@chakra-ui/react"; +import { useEffect, useMemo } from "react"; +import { countBy } from "lodash-es"; + +// Map from value to number of pulls that have that value +type ValueGetter = (pull: Pull) => string; + +const SHOWALL = "SHOWALL"; + +type FilterMenuProps = { + urlParam: string; + buttonText: string; + extractValueFromPull: ValueGetter; + defaultExculdedValues?: string[]; +}; + +export function FilterMenu({ + urlParam, + buttonText, + extractValueFromPull, + defaultExculdedValues, +}: FilterMenuProps) { + const pulls = useAllOpenPulls(); + // Default is empty array that implies show all pulls (no filtering) + const [selectedValues, setSelectedValues] = useArrayUrlState(urlParam, []); + // Nothing selected == show the default values (everything except the excluded values) + const showDefault = selectedValues.length === 0; + // Show every single value if the magic SHOWALL string is selected + const showAll = notEmpty(defaultExculdedValues) ? selectedValues.includes(SHOWALL) : selectedValues.length === 0; + + // List from url may contain values we have no pulls for + const urlValues = useConst(() => new Set(selectedValues)); + const setPullFilter = useSetFilter(); + + // May include values from the url for which there are no pulls + const allValues = useMemo(() => { + // All values of open pulls + const pullValues = new Set(pulls.map(extractValueFromPull)); + const allValuesSet = new Set([...pullValues, ...urlValues]); + allValuesSet.delete(SHOWALL); + return sortValues([...allValuesSet]); + }, [pulls]); + + const valueToPullCount = useMemo( + () => countBy(pulls, extractValueFromPull), + [pulls] + ); + + const defaultSelectedValues = arrayDiff(allValues, defaultExculdedValues || []); + + useEffect(() => { + const selectedValuesSet = new Set(selectedValues); + setPullFilter( + urlParam, + showAll + ? null + : (showDefault ? (pull) => !defaultExculdedValues?.includes(extractValueFromPull(pull)) + : (pull) => selectedValuesSet.has(extractValueFromPull(pull))) + ); + }, [selectedValues, defaultExculdedValues]); + + const numberText = showDefault ? "" : (showAll ? allValues.length : selectedValues.length); + + return ( + + + {buttonText} {numberText ? `(${numberText})` : ""} + + + {notEmpty(defaultExculdedValues) && ( + <> + setSelectedValues([SHOWALL])} + > + Show All + + setSelectedValues([])} + > + Show Default + + ) + } + {empty(defaultExculdedValues) && + setSelectedValues([])} + > + Show All + + } + + + {allValues.map((value) => ( + + {value} ({valueToPullCount[value] || 0}) + { + setSelectedValues([value]); + e.stopPropagation(); + } + }>only + + ))} + + + + ); +} + +function sortValues(values: string[]): string[] { + return values.sort((a: string, b: string) => + a.localeCompare(b, undefined, { sensitivity: "base" }) + ); +} + +function arrayDiff(a: T[], b: T[]): T[] { + return a.filter((x) => !b.includes(x)); +} + +function empty(array: T[] | undefined): boolean { + return !array || array.length === 0; +} + +function notEmpty(array: T[] | undefined): boolean { + return !!array && array.length > 0; +} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx new file mode 100644 index 00000000..cf0edf6e --- /dev/null +++ b/frontend/src/index.tsx @@ -0,0 +1,19 @@ +import { render } from "react-dom"; +import { Page } from "./pulldasher"; +import { PullsProvider } from "./pulldasher/pulls-context"; +import { ChakraProvider } from "@chakra-ui/react"; +import { theme } from "./theme"; + +const root = document.createElement("div"); +document.body.appendChild(root); + +function App() { + return ( + + + + + + ); +} +render(, root); diff --git a/frontend/src/leader-list.tsx b/frontend/src/leader-list.tsx new file mode 100644 index 00000000..6afdb1e7 --- /dev/null +++ b/frontend/src/leader-list.tsx @@ -0,0 +1,112 @@ +import { Pull } from "./pull"; +import { toDateString, userProfileUrl } from "./utils"; +import { Signature, SignatureUser } from "./types"; +import { Link, Flex, HStack, Box, chakra } from "@chakra-ui/react"; + +interface SigCount { + user: SignatureUser; + count: number; +} + +const DAYS_TO_EXAMINE = 14; + +export function LeaderList({ + leaders, + title, +}: { + leaders: SigCount[]; + title: string; +}) { + return ( + + + + {title}: + + {leaders.map(({ user, count }, i) => ( + + + + + + {count} + + + ))} + + + ); +} + +function Avatar({ user }: { user: SignatureUser }) { + return ( + + ); +} + +function colorForIndex(index: number): string { + return index < 2 + ? "var(--leaderboard-leader-background)" + : "var(--leaderboard-posting-background)"; +} + +export function getLeaders( + pulls: Pull[], + extractSigsFromPull: (pull: Pull) => Signature[] +) { + const recent = toDateString( + new Date(Date.now() - 86400 * 1000 * DAYS_TO_EXAMINE) + ); + const recentPulls = pulls.filter((pull) => { + return pull.closed_at ? pull.closed_at > recent : true; + }); + const sigCounts = new Map(); + recentPulls.forEach((pull) => { + const sigs: Signature[] = extractSigsFromPull(pull); + const recentSigs = sigs.filter((sig) => sig.data.created_at > recent); + // Count one sig per user from this pull + const seenUsers = new Set(); + recentSigs.forEach((sig) => { + const user = sig.data.user; + if (seenUsers.has(user.id)) { + return; + } + seenUsers.add(user.id); + const sigCount = sigCounts.get(user.id) || { user, count: 0 }; + sigCount.count++; + sigCounts.set(user.id, sigCount); + }); + }); + return Array.from(sigCounts.values()).sort( + (a: SigCount, b: SigCount) => b.count - a.count + ); +} diff --git a/frontend/src/navbar.tsx b/frontend/src/navbar.tsx new file mode 100644 index 00000000..6a9b35ba --- /dev/null +++ b/frontend/src/navbar.tsx @@ -0,0 +1,277 @@ +import { FilterMenu } from './filter-menu'; +import { getTitle } from './page-context'; +import { + useFilteredOpenPulls, + useAllOpenPulls, + useSetFilter, + useRepoSpecs, +} from './pulldasher/pulls-context'; +import { Pull } from './pull'; +import { + useColorMode, + Button, + HStack, + Center, + Flex, + Box, + BoxProps, + Input, + Menu, + MenuButton, + MenuItemOption, + MenuList, + Text, +} from '@chakra-ui/react'; +import { useRef, useEffect, useCallback, useMemo } from 'react'; +import { useBoolUrlState } from './use-url-state'; +import { NotificationRequest } from './notifications'; +import { useConnectionState, ConnectionState } from './backend/socket'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faMoon, + faCodeMerge, + faWifi, + faCircleNotch, + faXmark, + faCircleExclamation, + faUser, + faUsers, +} from '@fortawesome/free-solid-svg-icons'; + +// Default width of the left and right sections of the nav bar +const sideWidth = '220px'; + +type NavBarProps = BoxProps & { + toggleShowClosedPulls: () => void; + showClosedPulls: boolean; +}; + +export function Navbar(props: NavBarProps) { + const { toggleShowClosedPulls, showClosedPulls, ...boxProps } = props; + const pulls: Set = useFilteredOpenPulls(); + const allOpenPulls: Pull[] = useAllOpenPulls(); + const setPullFilter = useSetFilter(); + const repoSpecs = useRepoSpecs(); + const reposToHide = useMemo( + () => + repoSpecs.filter(repo => repo.hideByDefault).map(repo => repo.name.replace(/.*\//g, '')), + [repoSpecs] + ); + const { toggleColorMode } = useColorMode(); + const [showCryo, toggleShowCryo] = useBoolUrlState('cryo', false); + const [showExtBlocked, toggleShowExtBlocked] = useBoolUrlState('external_block', true); + const [showDrafts, toggleShowDrafts] = useBoolUrlState('drafts', false); + const [showPersonalView, togglePersonalView] = useBoolUrlState('personal', false); + const hideBelowMedium = ['none', 'none', 'block']; + const hideBelowLarge = ['none', 'none', 'none', 'block']; + + useEffect(() => setPullFilter('cryo', showCryo ? null : isNotCryogenic), [showCryo]); + useEffect( + () => setPullFilter('external_block', showExtBlocked ? null : isNotExternallyBlocked), + [showExtBlocked] + ); + useEffect(() => setPullFilter('drafts', showDrafts ? null : isNotDraftOrMine), [showDrafts]); + useEffect( + () => setPullFilter('personal', showPersonalView ? isMineViaAffiliation : null), + [showPersonalView] + ); + // Set the page title + const title = getTitle(); + useEffect(() => { + document.title = title || ''; + }, [title]); + + return ( +
+ + + + + + + + + Label + + + + Cryogenic Storage + + + External Block + + + Drafts + + + + + pull.getRepoName()} + /> + + + pull.user.login} + /> + + + + {title} + + + + + + + + + open: + + {pulls.size} + + + + + v2 + + + + + +
+ ); +} + +function isNotCryogenic(pull: Pull): boolean { + return !pull.getLabel('Cryogenic Storage'); +} + +function isNotExternallyBlocked(pull: Pull): boolean { + return !pull.getLabel('external_block'); +} + +function isNotDraftOrMine(pull: Pull): boolean { + return !pull.isDraft() || pull.isMine(); +} + +function isMineViaAffiliation(pull: Pull): boolean { + return pull.isMineViaAffiliation(); +} + +function SearchInput() { + const setPullFilter = useSetFilter(); + const searchInputRef = useRef(null); + const updateSearchFilter = useCallback((event: React.ChangeEvent) => { + const patterns = event.target.value + .trim() + .split(/\s+/) + .filter(s => s.length) + .map(s => new RegExp(s, 'i')); + setPullFilter( + 'search', + patterns.length + ? (pull: Pull) => { + return patterns.every( + pattern => pull.title.match(pattern) || pull.repo.match(pattern) + ); + } + : null + ); + }, []); + + useHotkeys('/', event => { + if (searchInputRef.current) { + searchInputRef.current.focus(); + searchInputRef.current.select(); + event.preventDefault(); + } + }); + + return ( + + ); +} + +function ConnectionStateIndicator() { + const connectionState = useConnectionState(); + if (connectionState == ConnectionState.connected) { + return ; + } + if (connectionState == ConnectionState.connecting) { + return ; + } + if (connectionState == ConnectionState.disconnected) { + return ; + } + if (connectionState == ConnectionState.error) { + return ; + } + return null; +} diff --git a/frontend/src/notifications.tsx b/frontend/src/notifications.tsx new file mode 100644 index 00000000..41b03dd6 --- /dev/null +++ b/frontend/src/notifications.tsx @@ -0,0 +1,111 @@ +import * as React from "react"; +import { Button } from "@chakra-ui/react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faBellSlash, + faVolumeHigh, + faVolumeXmark, +} from "@fortawesome/free-solid-svg-icons"; +import { isEqual } from "lodash-es"; + +type KeyType = number | string; + +type NotificationOptions = { + filter: (x: T) => boolean; + message: (xs: T[]) => string; + key: (x: T) => KeyType; +}; + +const notificationsSupported = "Notification" in window; + +function isBellActive() { + return window.localStorage.getItem("bell") === "ring"; +} + +export function useNotification( + xs: T[], + { filter, message, key }: NotificationOptions +) { + if (!notificationsSupported) { + return; + } + const [seen, setSeen] = React.useState([]); + const filtered = xs.filter(filter); + const unseen = filtered.filter((x) => !seen.includes(key(x))); + React.useEffect(() => { + if (unseen.length > 0) { + const msg = message(unseen); + if (Notification.permission === "granted") { + new Notification(msg); + if (isBellActive()) { + new Audio("/public/sounds/bell.mp3").play(); + } + } + } + const proposed = filtered.map(key); + + if (!isEqual(seen, proposed)) { + setSeen(proposed); + } + }); +} + +export function NotificationRequest() { + const [bellActive, setBellActive] = React.useState(isBellActive()); + + const activateBell = () => { + window.localStorage.setItem("bell", "ring"); + setBellActive(true); + }; + + const deactivateBell = () => { + window.localStorage.removeItem("bell"); + setBellActive(false); + }; + + const activateNotifications = () => { + Notification.requestPermission(); + }; + + if (!notificationsSupported) { + return null; + } + if (Notification.permission === "default") { + return ( + + ); + } + + if (bellActive) { + return ( + + ); + } + return ( + + ); +} diff --git a/frontend/src/page-context.ts b/frontend/src/page-context.ts new file mode 100644 index 00000000..dfd481e2 --- /dev/null +++ b/frontend/src/page-context.ts @@ -0,0 +1,29 @@ +interface tokenResponse { + socketToken: string; + user: string; + title: string; +} + +let pageContext: tokenResponse; + +if (process.env.DUMMY_USER) { + pageContext = { + user: process.env.DUMMY_USER, + socketToken: "fake-token", + title: "Pulldasher", + }; +} + +export function getUser() { + return pageContext && pageContext.user; +} + +export function getTitle() { + return pageContext && pageContext.title; +} + +export function getPageContext(): Promise { + return fetch("/token") + .then((response) => response.json()) + .then((response) => (pageContext = response)); +} diff --git a/frontend/src/pull-card/age.tsx b/frontend/src/pull-card/age.tsx new file mode 100644 index 00000000..e9fa10b9 --- /dev/null +++ b/frontend/src/pull-card/age.tsx @@ -0,0 +1,28 @@ +import { DateString } from "../types"; +import { Box } from "@chakra-ui/react"; + +export function Age({ created_at }: { created_at: DateString }) { + const createdAt = new Date(created_at); + const timeDifference = Date.now() - createdAt.getTime(); + const daysSinceCreation = Math.ceil(timeDifference / (1000 * 3600 * 24)); + const numDots = Math.min(daysSinceCreation, 30); + const dot = "•"; + const tooltip = `opened at ${createdAt.toLocaleString()}`; + + return ( + + {dot.repeat(numDots)} + + ); +} + +function getAgeColor(days: number): string { + switch (Math.ceil(days / 4)) { + case 1: + return "--age-1"; + case 2: + return "--age-2"; + default: + return "--age-3"; + } +} diff --git a/frontend/src/pull-card/avatar.tsx b/frontend/src/pull-card/avatar.tsx new file mode 100644 index 00000000..1cfc7f89 --- /dev/null +++ b/frontend/src/pull-card/avatar.tsx @@ -0,0 +1,38 @@ +import { Img } from "@chakra-ui/react"; +import { userProfileUrl } from "../utils"; + +export function Avatar({ + user, + linkToProfile, + title, +}: { + user: string; + linkToProfile?: boolean; + title?: string; +}) { + const cleanUsername = user.replace(/\[bot\]$/, ""); + return ( + + ); +} + +function avatarClickHandler(event: React.MouseEvent) { + const user: string | undefined = event.currentTarget?.dataset.user; + if (!user) { + return; + } + window.open(userProfileUrl(user), "_blank"); + event.preventDefault(); +} diff --git a/frontend/src/pull-card/commit-statuses.tsx b/frontend/src/pull-card/commit-statuses.tsx new file mode 100644 index 00000000..60098421 --- /dev/null +++ b/frontend/src/pull-card/commit-statuses.tsx @@ -0,0 +1,146 @@ +import { Pull } from "../pull"; +import { CommitStatus, StatusState } from "../types"; +import { newTab, useDurationMinutes } from "../utils"; +import { + chakra, + Box, + useStyleConfig, + Popover, + PopoverTrigger, + PopoverContent, + PopoverBody, + PopoverArrow, + PopoverCloseButton, + Portal, + BoxProps, +} from "@chakra-ui/react"; +import { groupBy } from "lodash-es"; +import { memo } from "react"; +import styled from "@emotion/styled"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faCircleExclamation, + faCircleXmark, + faCircleCheck, + faClock, + IconDefinition, +} from "@fortawesome/free-solid-svg-icons"; + +const statusSize = 10; +const marginBetween = 4; + +const StatusContainer = styled.div` + position: absolute; + top: 0; + left: 0; + bottom: 0; + margin: 6px; + width: ${statusSize}px; + display: flex; + flex-shrink: 0; + flex-direction: column; + justify-content: space-between; + gap: ${marginBetween}px; + cursor: pointer; +`; + +const StatusLinkContainer = Box; + +function StatusLink({ status }: { status: CommitStatus }) { + const styles = useStyleConfig("StatusLink", { variant: status.data.state }); + const link = status.data.target_url; + const duration = useDurationMinutes(status); + return ( + newTab(link) : undefined} + > + + {status.data.context}: + {status.data.description} + {duration && {Math.ceil(duration) + "m"}} + + ); +} + +function iconForStatus(status: CommitStatus): IconDefinition { + const state = status.data.state; + return state === StatusState.pending + ? faClock + : state === StatusState.error + ? faCircleExclamation + : state === StatusState.failure + ? faCircleXmark + : faCircleCheck; +} + +type StatusGroupProps = BoxProps & { + statuses: CommitStatus[]; +}; + +function StatusGroup({ statuses, ...props }: StatusGroupProps) { + const state = statuses[0].data.state; + const styles = useStyleConfig("StatusGroup", { variant: state }); + const contexts = statuses.map((status) => status.data.context).join("\n"); + const title = `${state}:${statuses.length > 1 ? "\n" : " "}${contexts}`; + return ( + + ); +} + +function SingleStatus(status: CommitStatus) { + const link = status.data.target_url; + return ( + + newTab(link) : undefined} + statuses={[status]} + /> + + ); +} + +export const CommitStatuses = memo(function CommitStatuses({ + pull, +}: { + pull: Pull; +}) { + const statuses = pull.buildStatusesWithRequired(); + if (statuses.length === 0) { + return null; + } else if (statuses.length === 1) { + return SingleStatus(statuses[0]); + } + const grouped = groupBy(statuses, (status) => status.data.state); + return ( + + + + {grouped.success && } + {grouped.pending && } + {grouped.failure && } + {grouped.error && } + + + + + + + + {statuses.map((status) => ( + + ))} + + + + + ); +}); diff --git a/frontend/src/pull-card/copy-branch.tsx b/frontend/src/pull-card/copy-branch.tsx new file mode 100644 index 00000000..8d098eac --- /dev/null +++ b/frontend/src/pull-card/copy-branch.tsx @@ -0,0 +1,34 @@ +import { memo } from "react"; +import { useClipboard, chakra } from "@chakra-ui/react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCopy, faCheck } from "@fortawesome/free-solid-svg-icons"; + +const CopyBranchIcon = chakra(FontAwesomeIcon, { + baseStyle: { + marginLeft: "10px", + cursor: "pointer", + color: "var(--copy-branch)", + "&:hover": { + color: "var(--copy-branch-hover)", + }, + }, +}); + +export const CopyBranch = memo(function CopyBranch({ + className, + value, +}: { + className: string; + value: string; +}) { + const { onCopy, hasCopied } = useClipboard(value); + return ( + + ); +}); diff --git a/frontend/src/pull-card/flags.tsx b/frontend/src/pull-card/flags.tsx new file mode 100644 index 00000000..333f630a --- /dev/null +++ b/frontend/src/pull-card/flags.tsx @@ -0,0 +1,135 @@ +import { Pull } from "../pull"; +import { actionMessage } from "../utils"; +import { Link, Box, useStyleConfig } from "@chakra-ui/react"; +import { memo } from "react"; +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faWarning, + faMinusCircle, + faEye, + faEyeSlash, + faSnowflake, + faSpinner, + faCodeCompare, + faCodeBranch, +} from "@fortawesome/free-solid-svg-icons"; + +export const Flags = memo(function Flags({ pull }: { pull: Pull }) { + const devBlock = pull.getDevBlock(); + const draftBlock = pull.isDraft(); + const readyButNoCI = pull.isReady() && !pull.isCiRequired(); + const dependentPR = pull.isDependent(); + const deployBlock = pull.getDeployBlock(); + const QAing = pull.getLabel("QAing"); + const externalBlock = pull.getLabel("external_block"); + const cryogenicStorage = pull.getLabel("Cryogenic Storage"); + return ( + <> + {deployBlock && ( + + )} + {readyButNoCI && ( + + )} + {dependentPR && ( + + )} + {devBlock && ( + + )} + {draftBlock && ( + + )} + {QAing && ( + + )} + {externalBlock && ( + + )} + {cryogenicStorage && ( + + )} + {pull.hasMergeConflicts() && ( + + )} + + ); +}); + +interface PullFlagProps { + href?: string; + variant: string; + title?: string; + icon: IconDefinition; +} + +function PullFlag({ variant, title, href, icon }: PullFlagProps) { + const styles = useStyleConfig("PullFlag", { variant: variant }); + return href ? ( + + + + ) : ( + + + + ); +} diff --git a/frontend/src/pull-card/index.tsx b/frontend/src/pull-card/index.tsx new file mode 100644 index 00000000..64c906d7 --- /dev/null +++ b/frontend/src/pull-card/index.tsx @@ -0,0 +1,221 @@ +import { Pull } from "../pull"; +import { CommitStatuses } from "./commit-statuses"; +import { Participants } from "./participants"; +import { Age } from "./age"; +import { Flags } from "./flags"; +import { Avatar } from "./avatar"; +import { Signatures } from "./signatures"; +import { CopyBranch } from "./copy-branch"; +import { memo, useEffect, useRef, RefObject } from "react"; +import { RefreshButton } from "./refresh"; +import { Flex, Box, Link, chakra, Text } from "@chakra-ui/react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faStar, + faCodeMerge, + faXmark, +} from "@fortawesome/free-solid-svg-icons"; + +const Card = chakra(Flex, { + baseStyle: { + position: "relative", + p: 4, + pl: 6, + borderTop: "1px var(--pull-separator) solid", + "&:hover .build_status": { + opacity: 1, + }, + "& .copy": { + visibility: "hidden", + }, + "&:hover .copy": { + visibility: "visible", + }, + "&:hover .refresh": { + visibility: "visible", + }, + "& .star": { + marginRight: "0.5em", + }, + "&.highlight": { + animation: "highlightPull 2s", + }, + }, +}); + +const SigsAndFlags = chakra(Flex, { + baseStyle: { + mt: 3, + "& > *": { + marginRight: "5px", + marginBottom: "5px", + }, + }, +}); + +export const PullCard = memo(function PullCard({ + pull, + show, + showLinesChanged, +}: { + pull: Pull; + show: boolean; + showLinesChanged?: boolean; +}) { + const cardRef = useRef(null); + highlightOnChange(cardRef, [pull.received_at]); + + const linesAvailable = (pull.additions != null) && (pull.deletions != null); + + return ( + + + + + + + {pull.authoredByMe() ? ( + + ) : ( + + )} + + {pull.getRepoName()} #{pull.number}:{" "} + + {pull.title} + + {showLinesChanged && linesAvailable && + <> + + +{pull.additions} + + + -{pull.deletions} + + + } + + + + + + + + + + ); +}); + +export const ClosedPullCard = memo(function ClosedPullCard({ + pull, + show, +}: { + pull: Pull; + show: boolean; +}) { + const wasMerged = !!pull.merged_at; + return ( + + + + + + {wasMerged ? ( + + ) : ( + + )} + + + {formatDate(pull.closed_at)} + + + + {pull.authoredByMe() ? ( + + ) : ( + + )} + + {pull.getRepoName()} #{pull.number}:{" "} + + {pull.title} + + + + ); +}); + +const formatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "short", + timeStyle: "short", +}); + +const formatDate = (dateStr: string | null) => { + return dateStr ? formatter.format(new Date(dateStr)) : null; +}; + +function highlightOnChange( + ref: RefObject, +// eslint-disable-next-line @typescript-eslint/no-explicit-any + dependencies: Array +) { + // Animate a highlight when pull.received_at changes + useEffect(() => { + ref.current?.classList.add("highlight"); + // 1s after the animation, remove the class + setTimeout(() => ref.current?.classList.remove("highlight"), 3000); + }, dependencies); +} diff --git a/frontend/src/pull-card/participants.tsx b/frontend/src/pull-card/participants.tsx new file mode 100644 index 00000000..c9dff32f --- /dev/null +++ b/frontend/src/pull-card/participants.tsx @@ -0,0 +1,36 @@ +import { Pull } from "../pull"; +import { + Box +} from "@chakra-ui/react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faUser, + faUsers, +} from "@fortawesome/free-solid-svg-icons"; + +export function Participants({ pull }: { pull: Pull }) { + if (!pull.participants?.length) { + return null; + } + return ( + + 1 ? faUsers : faUser} + title={tooltip(pull)} + color={pull.participating() ? "var(--participants-including-me)" : "var(--participants-without-me)"} + /> + + ); +} + +function tooltip(pull: Pull) { + if (pull.participants.length == 1) { + return pull.participating() ? + "Only you participating" : + "1 participant" + } else { + return pull.participating() ? + `${pull.participants.length} participants (including you)` : + `${pull.participants.length} participants`; + } +} diff --git a/frontend/src/pull-card/refresh.tsx b/frontend/src/pull-card/refresh.tsx new file mode 100644 index 00000000..d189afdf --- /dev/null +++ b/frontend/src/pull-card/refresh.tsx @@ -0,0 +1,59 @@ +import { Pull } from "../pull"; +import { refreshPull } from "../backend/pull-socket"; +import { Box, chakra } from "@chakra-ui/react"; +import { useState, memo } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faRefresh } from "@fortawesome/free-solid-svg-icons"; + +const RefreshContainer = chakra(Box, { + baseStyle: { + lineHeight: 0.9, + visibility: "hidden", + position: "absolute", + cursor: "pointer", + bottom: "3px", + right: "3px", + borderRadius: "4px", + borderWidth: "1px", + borderColor: "var(--refresh)", + padding: "5px", + background: "var(--refresh-background)", + color: "var(--refresh)", + "&.refreshing": { + visibility: "visible", + }, + "&:hover": { + color: "var(--refresh-hover)", + borderColor: "var(--refresh-hover)", + }, + "&:active": { + color: "white", + backgroundColor: "var(--refresh-hover)", + }, + }, +}); + +export const RefreshButton = memo(function RefreshButton({ + pull, +}: { + pull: Pull; +}) { + const [oldReceivedAt, setOldReceivedAt] = useState(new Date()); + const waitingOnRefreshing = oldReceivedAt == pull.received_at; + const handleOnClick = () => { + refreshPull(pull); + setOldReceivedAt(pull.received_at); + }; + return ( + + + + ); +}); diff --git a/frontend/src/pull-card/signatures.tsx b/frontend/src/pull-card/signatures.tsx new file mode 100644 index 00000000..992cda87 --- /dev/null +++ b/frontend/src/pull-card/signatures.tsx @@ -0,0 +1,192 @@ +import { getUser } from "../page-context"; +import { Pull } from "../pull"; +import { Avatar } from "./avatar"; +import { formatDate } from "../utils"; +import { Signature, SignatureGroup } from "../types"; +import { + chakra, + Box, + HStack, + useStyleConfig, + Popover, + PopoverTrigger, + PopoverContent, + PopoverBody, + PopoverArrow, + PopoverCloseButton, + Portal, +} from "@chakra-ui/react"; +import { memo } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCheckCircle, faStar } from "@fortawesome/free-solid-svg-icons"; + +interface SignaturesProps { + pull: Pull; + title: string; + signatures: SignatureGroup; + required: number; + // When set (the CR block), show a blue star before the title if a review + // is requested from the current user. + showReviewRequest?: boolean; +} + +const SigSectionTitle = Box; + +function SignaturesFlag({ + pull, + title, + signatures, + required, + showReviewRequest, +}: SignaturesProps) { + const statusVariant = + signatures.current.length >= required + ? pull.isMine() + ? "validMine" + : "valid" + : undefined; + const styles = useStyleConfig("Signatures", { variant: statusVariant }); + const allSignatures = [...signatures.current, ...signatures.old]; + const requiredSignatures = required + ? allSignatures.slice(0, required) + : allSignatures; + const unfulfilledCount = Math.max(0, required - allSignatures.length); + const noneToShow = required == 0 && allSignatures.length == 0; + + return ( + + {showReviewRequest && pull.reviewRequestedFromMe() && ( + + )} + + {title} + + {requiredSignatures.map((sig) => ( + + ))} + {UnfullfilledSigs(unfulfilledCount)} + + ); +} + +export const Signatures = memo(function Signatures(props: SignaturesProps) { + const { pull, signatures } = props; + const noSignatures = + signatures.current.length === 0 && signatures.old.length === 0; + + if (noSignatures) { + return ; + } + + return ( + + + + + + + + + + + + {signatures.current.length > 0 && ( + <> + + Signed Off + + + + )} + {signatures.old.length > 0 && ( + <> + + Previously Signed Off + + + + )} + + + + + ); +}); + +function SignatureBubble({ sig }: { sig: Signature }) { + return ( + + + + ); +} + +function UnfullfilledSigs(count: number) { + const sigs = new Array(count); + for (let i = 0; i < count; i++) { + sigs[i] = ( + + ); + } + return sigs; +} + +function SignatureList({ sigs, pull }: { sigs: Signature[]; pull: Pull }) { + return ( + + {sigs.map((sig) => ( + + ))} + + ); +} + +function SignatureListItem({ sig, pull }: { sig: Signature; pull: Pull }) { + const styles = useStyleConfig("SignatureListItem"); + return ( + + + {formatDate(sig.data.created_at)} by {sig.data.user.login} + + ); +} + +function colorForSignature(sig: Signature): string { + if (sig.data.active) { + return getUser() === sig.data.user.login + ? "var(--signature-valid-mine)" + : "var(--signature-valid)"; + } else { + return getUser() === sig.data.user.login + ? "var(--signature-invalid-mine)" + : "var(--signature-invalid)"; + } +} diff --git a/frontend/src/pull.ts b/frontend/src/pull.ts new file mode 100644 index 00000000..8014350b --- /dev/null +++ b/frontend/src/pull.ts @@ -0,0 +1,270 @@ +import { sortBy } from "lodash-es"; +import { getUser } from "./page-context"; +import { signatureCompare } from "./pulldasher/sort"; +import { + PullData, + Signature, + CommitStatus, + StatusState, + CommentSource, + SignatureGroup, +} from "./types"; + +export class Pull extends PullData { + cr_signatures: SignatureGroup; + qa_signatures: SignatureGroup; + + constructor(data: PullData) { + super(); + Object.assign(this, data); + + this.cr_signatures = computeSignatures(data.status.allCR); + this.qa_signatures = computeSignatures(data.status.allQA); + } + + isOpen(): boolean { + return this.state == "open"; + } + + getUrl(): string { + return "https://github.com/" + this.repo + "/pull/" + this.number; + } + + getRepoName(): string { + return this.repo.replace(/.*\//g, ""); + } + + // Returns a string that is stable and unique to this pull + getKey(): string { + return this.repo + "#" + this.number; + } + + isMine(): boolean { + return this.user.login == getUser(); + } + + isMineViaAffiliation(): boolean { + return ( + this.authoredByMe() || + this.reviewRequestedFromMe() || + this.hasCurrentSig(getUser()) || + this.hasOutdatedSig(getUser()) || + this.hasMyDevBlock() || + this.hasMyDeployBlock() || + this.participating() + ); + } + + isCrDone(): boolean { + return this.cr_signatures.current.length >= this.status.cr_req; + } + + isQaDone(): boolean { + return this.qa_signatures.current.length >= this.status.qa_req; + } + + isCiBlocked(): boolean { + return !this.getDevBlock() && !this.hasPassedCI() && !this.isDraft(); + } + + hasOutdatedSig(user: string) { + return ( + this.cr_signatures.old.some((sig) => sig.data.user.login == user) || + this.qa_signatures.old.some((sig) => sig.data.user.login == user) + ); + } + + hasCurrentSig(user: string) { + return ( + this.cr_signatures.current.some((sig) => sig.data.user.login == user) || + this.qa_signatures.current.some((sig) => sig.data.user.login == user) + ); + } + + participating(): boolean { + return this.participants && this.participants.includes(getUser()); + } + + // The pull author plus its assignees, treated as a combined author list. + authors(): string[] { + return [this.user.login, ...(this.assignees ?? [])].filter( + (login, i, all) => all.indexOf(login) === i + ); + } + + authoredByMe(): boolean { + return this.authors().includes(getUser()); + } + + reviewRequestedFromMe(): boolean { + return this.requested_reviewers?.includes(getUser()) ?? false; + } + + hasMyDevBlock(): boolean { + return this.getDevBlock()?.data.user.login == getUser(); + } + + hasMyDeployBlock(): boolean { + return this.getDeployBlock()?.data.user.login == getUser(); + } + + getDevBlock(): Signature | null { + return this.status.dev_block[0]; + } + + /** + * Returns true if the pull request is in a draft state + */ + isDraft(): boolean { + return this.draft; + } + + /** + * Returns true if the CI requirement has been met + */ + hasPassedCI(): boolean { + const statuses = this.buildStatuses(); + return this.getRequiredBuildStatuses().every((context) => { + const status = statuses.find((status) => status.data.context == context); + return status && isSuccessfulStatus(status); + }); + } + + /** + * Returns true if there are required CI statues OR if there are *any* CI + * statuses + */ + isCiRequired(): boolean { + return ( + this.getRequiredBuildStatuses().length > 0 || + this.buildStatuses().length > 0 + ); + } + + isReady(): boolean { + return ( + this.hasMetDeployRequirements() && + !this.isDraft() && + !this.getDevBlock() && + !this.getDeployBlock() && + !this.hasMergeConflicts() + ); + } + + isDeployBlocked(): boolean { + return ( + this.hasMetDeployRequirements() && + !this.getDevBlock() && + (!!this.getDeployBlock() || this.hasMergeConflicts() || this.isDependent()) + ); + } + + isDependent(): boolean { + return !(this.base.ref == "main" || this.base.ref == "master"); + } + + hasMetDeployRequirements(): boolean { + return this.isQaDone() && this.isCrDone() && this.hasPassedCI(); + } + + getDeployBlock(): Signature | null { + return this.status.deploy_block[0]; + } + + getLabel(title: string) { + return this.labels.find((label) => label.title == title); + } + + buildStatuses(): CommitStatus[] { + return this.status.commit_statuses; + } + + hasBuildStatus(context: string): boolean { + return this.status.commit_statuses.some( + (status) => status.data.context === context + ); + } + + hasMergeConflicts(): boolean { + return this.mergeable == false; + } + + buildStatusesWithRequired(): CommitStatus[] { + const statuses = this.buildStatuses(); + (this.repoSpec?.requiredStatuses || []).forEach((requiredContext) => { + if (!this.hasBuildStatus(requiredContext)) { + statuses.push({ + data: { + sha: "unknown", // unused and doesn't matter + target_url: null, + description: "Missing (not started)", + state: StatusState.pending, + context: requiredContext, + started_at: null, + completed_at: null, + }, + }); + } + }); + return sortBy(statuses, [(status) => status.data.context.toLowerCase()]); + } + + getRequiredBuildStatuses(): string[] { + return ( + this.repoSpec?.requiredStatuses ?? + // If there are no required statuses, then all existing statuses + // are required to be passing unless they are ignored. + this.getBuildStatusesWithoutIgnored() + ); + } + + getBuildStatusesWithoutIgnored(): string[] { + return this.buildStatuses() + .map((status) => status.data.context) + .filter((context) => + !this.repoSpec?.ignoredStatuses?.includes(context) + ); + } + + url() { + return "https://github.com/" + this.repo + "/pull/" + this.number; + } + + linkToSignature(sig: Signature): string { + const isReview = sig.data.source_type === CommentSource.review; + const linkIdPrefix = isReview ? "pullrequestreview" : "issuecomment"; + return this.url() + `#${linkIdPrefix}-${sig.data.comment_id}`; + } + + getUrlOfBaseBranch() { + return "https://github.com/" + this.repo + "/compare/" + encodeURIComponent(this.base.ref); + } +} + +function computeSignatures(signatures: Signature[]): SignatureGroup { + const groups: SignatureGroup = { + current: [], + old: [], + }; + const users: Record = {}; + + signatures.sort(signatureCompare).forEach(function (signature) { + if (users[signature.data.user.login]) { + return; + } + + if (signature.data.active) { + groups.current.push(signature); + } else { + groups.old.push(signature); + } + + users[signature.data.user.login] = true; + }); + + return groups; +} + +function isSuccessfulStatus(status: CommitStatus) { + return status.data.state === "success"; +} diff --git a/frontend/src/pulldasher/filtered-pulls-state.ts b/frontend/src/pulldasher/filtered-pulls-state.ts new file mode 100644 index 00000000..5b745f7b --- /dev/null +++ b/frontend/src/pulldasher/filtered-pulls-state.ts @@ -0,0 +1,44 @@ +import { useState, useCallback } from "react"; +import { Pull } from "../pull"; + +export type FilterFunction = (pull: Pull) => boolean; +export type FilterFunctionSetter = ( + filterName: string, + filter: FilterFunction | null +) => void; +// Map of human name to filter functions +type Filters = Record; +type ReturnType = [Pull[], FilterFunctionSetter]; + +/** + * Wrapper around an array of pulls that allows multiple filters to be + * specified. + * setFilter(name, func) will *remove* the filter if func is null + */ +export function useFilteredPullsState(pulls: Pull[]): ReturnType { + const [filters, setFilter] = useState({}); + const replaceNamedFilter = useCallback( + (filterName: string, filter: FilterFunction | null) => { + // Use the functional form of `setState()` so we can base our new + // value on the previous one. + setFilter((currentFilters) => { + if (filter) { + currentFilters[filterName] = filter; + } else { + delete currentFilters[filterName]; + } + return { ...currentFilters }; + }); + }, + [] + ); + + return [filterPulls(pulls, filters), replaceNamedFilter]; +} + +function filterPulls(pulls: Pull[], filters: Filters): Pull[] { + return Object.values(filters).reduce( + (pulls, filter) => pulls.filter(filter), + pulls + ); +} diff --git a/frontend/src/pulldasher/index.tsx b/frontend/src/pulldasher/index.tsx new file mode 100644 index 00000000..58b2df62 --- /dev/null +++ b/frontend/src/pulldasher/index.tsx @@ -0,0 +1,109 @@ +import { useAllPulls, useAllOpenPulls } from "./pulls-context"; +import { Navbar } from "../navbar"; +import { Column } from "../column"; +import { QACompare, DeployCompare } from "./sort"; +import { LeaderList, getLeaders } from "../leader-list"; +import { + useMyPullNotification, + useMyReviewNotification, +} from "./notifications"; +import { Box, SimpleGrid, VStack } from "@chakra-ui/react"; +import { useBoolUrlState } from "../use-url-state"; +import { ClosedPulls } from "../closed-pulls"; + +export function Page() { + const [showClosedPulls, toggleShowClosedPulls] = useBoolUrlState( + "closed", + false + ); + return ( + <> + + + {showClosedPulls && } + + ); +} + +function Pulldasher() { + const allPulls = useAllPulls(); + const pulls = useAllOpenPulls(); + const pullsCIBlocked = pulls.filter((pull) => pull.isCiBlocked()); + const pullsDeployBlocked = pulls.filter((pull) => pull.isDeployBlocked()); + const pullsReady = pulls.filter( + (pull) => pull.isReady() && !pull.isDependent() + ); + const pullsDevBlocked = pulls.filter( + (pull) => pull.getDevBlock() || pull.isDraft() + ); + const pullsNeedingCR = pulls.filter( + (pull) => !pull.isCrDone() && !pull.getDevBlock() && !pull.isDraft() + ); + const pullsNeedingQA = pulls.filter( + (pull) => + !pull.isQaDone() && + !pull.getDevBlock() && + !pull.isDraft() && + !pull.hasMergeConflicts() && + pull.hasPassedCI() + ); + const leadersCR = getLeaders(allPulls, (pull) => pull.status.allCR); + useMyPullNotification(pullsReady, "merge"); + useMyReviewNotification([...pullsNeedingCR, ...pullsNeedingQA], "re-review"); + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/pulldasher/notifications.tsx b/frontend/src/pulldasher/notifications.tsx new file mode 100644 index 00000000..16278ccb --- /dev/null +++ b/frontend/src/pulldasher/notifications.tsx @@ -0,0 +1,28 @@ +import { useNotification } from "../notifications"; +import { getUser } from "../page-context"; +import { Pull } from "../pull"; + +export function usePullNotification( + pulls: Pull[], + action: string, + filter: (pull: Pull) => boolean +) { + useNotification(pulls, { + key: (pull: Pull) => pull.number, + filter, + message(pulls) { + const titles = pulls.map((p) => p.title).join(", "); + const pull = pulls.length === 1 ? "Pull" : "Pulls"; + return `${pull} ready for ${action}: ${titles}`; + }, + }); +} + +export function useMyPullNotification(pulls: Pull[], action: string) { + usePullNotification(pulls, action, (pull: Pull) => pull.isMine()); +} +export function useMyReviewNotification(pulls: Pull[], action: string) { + usePullNotification(pulls, action, (pull: Pull) => + pull.hasOutdatedSig(getUser()) + ); +} diff --git a/frontend/src/pulldasher/pulls-context.tsx b/frontend/src/pulldasher/pulls-context.tsx new file mode 100644 index 00000000..6d49f2a5 --- /dev/null +++ b/frontend/src/pulldasher/pulls-context.tsx @@ -0,0 +1,86 @@ +import { createContext, useContext } from "react"; +import { + useFilteredPullsState, + FilterFunction, + FilterFunctionSetter, +} from "./filtered-pulls-state"; +import { usePullsState } from "./pulls-state"; +import { Pull } from "../pull"; +import { RepoSpec } from "../types"; +import { defaultCompare } from "./sort"; + +interface PullContextProps { + // Unsorted array of all pulls (open + recently closed) + allPulls: Pull[]; + // Sorted array of all open pulls (open) + allOpenPulls: Pull[]; + // Array of pulls from allPulls that pass the filter function + filteredPulls: Pull[]; + // Set of open pulls passing the filter function + filteredOpenPulls: Set; + // Changes the filter function + setFilter: FilterFunctionSetter; + // RepoSpecs (.repos) from the config + repoSpecs: RepoSpec[]; +} + +const defaultProps = { + allPulls: [], + allOpenPulls: [], + filteredOpenPulls: new Set(), + filteredPulls: [], + // Default implementation is a no-op, just so there's + // something there until the provider is used + setFilter: (name: string, filter: FilterFunction) => filter, + repoSpecs: [], +}; +const PullsContext = createContext(defaultProps); + +export function useAllOpenPulls(): Pull[] { + return useContext(PullsContext).allOpenPulls; +} + +export function useAllPulls(): Pull[] { + return useContext(PullsContext).allPulls; +} + +export function useFilteredOpenPulls(): Set { + return useContext(PullsContext).filteredOpenPulls; +} + +export function useFilteredPulls(): Pull[] { + return useContext(PullsContext).filteredPulls; +} + +export function useSetFilter(): FilterFunctionSetter { + return useContext(PullsContext).setFilter; +} + +export function useRepoSpecs(): RepoSpec[] { + return useContext(PullsContext).repoSpecs; +} + +export const PullsProvider = function ({ + children, +}: { + children: React.ReactNode; +}) { + const {pullState: unfilteredPulls, repoSpecs} = usePullsState(); + const [filteredPulls, setFilter] = useFilteredPullsState(unfilteredPulls); + const openPulls = unfilteredPulls.filter(isOpen); + const contextValue = { + filteredOpenPulls: new Set(filteredPulls.filter(isOpen)), + allOpenPulls: openPulls.sort(defaultCompare), + filteredPulls: filteredPulls, + allPulls: unfilteredPulls, + setFilter, + repoSpecs, + }; + return ( + + {children} + + ); +}; + +const isOpen = (pull: Pull) => pull.isOpen(); diff --git a/frontend/src/pulldasher/pulls-state.tsx b/frontend/src/pulldasher/pulls-state.tsx new file mode 100644 index 00000000..f2f7496a --- /dev/null +++ b/frontend/src/pulldasher/pulls-state.tsx @@ -0,0 +1,47 @@ +import { useState, useEffect } from "react"; +import { throttle } from "lodash-es"; +import { createPullSocket } from "../backend/pull-socket"; +import { PullData, RepoSpec } from "../types"; +import { Pull } from "../pull"; + +function onPullsChanged(pullsChanged: (pulls: Pull[], repoSpecs: RepoSpec[]) => void) { + const pulls: Record = {}; + const pullRefresh = () => pullsChanged(Object.values(pulls), repoSpecs); + const throttledPullRefresh: () => void = throttle(pullRefresh, 500); + let repoSpecs: RepoSpec[] = []; + + createPullSocket((pullDatas: PullData[], newRepoSpecs: RepoSpec[]) => { + pullDatas.forEach((pullData: PullData) => { + pullData.repoSpec = + newRepoSpecs.find((repo) => repo.name == pullData.repo) || null; + pullData.received_at = new Date(); + const pull: Pull = new Pull(pullData); + pulls[pull.getKey()] = pull; + }); + + repoSpecs = newRepoSpecs || []; + throttledPullRefresh(); + }); +} + +/** + * Note: This is only meant to be used in one component + */ +let socketInitialized = false; +export function usePullsState() { + const [pullState, setPullsState] = useState([]); + const [repoSpecs, setRepoSpecs] = useState([]); + useEffect(() => { + if (socketInitialized) { + throw new Error( + "usePullsState() connects to socket-io and is only meant to be used in the PullsProvider component, see useFilteredOpenPulls() instead." + ); + } + socketInitialized = true; + onPullsChanged((pulls, repoSpecs) => { + setPullsState(pulls); + setRepoSpecs(repoSpecs); + }); + }, []); + return {pullState, repoSpecs}; +} diff --git a/frontend/src/pulldasher/sort.ts b/frontend/src/pulldasher/sort.ts new file mode 100644 index 00000000..388b01e1 --- /dev/null +++ b/frontend/src/pulldasher/sort.ts @@ -0,0 +1,69 @@ +import { Pull } from "../pull"; +import { getUser } from "../page-context"; +import { Signature } from "../types"; + +export function defaultCompare(a: Pull, b: Pull): number { + return ( + // My pulls above pulls that aren't mine + compareBool(a.isMine(), b.isMine()) || + // Pulls I have to CR/QA above those I don't + compareBool(a.hasOutdatedSig(getUser()), b.hasOutdatedSig(getUser())) || + // Pulls I haven't touched vs those I have already CRed + compareBool(!a.hasCurrentSig(getUser()), !b.hasCurrentSig(getUser())) || + // younger pulls above older pulls so new things get review sooner + b.created_at.localeCompare(a.created_at) + ); +} + +export function QACompare(a: Pull, b: Pull): number { + return ( + // Pulls I'm QAing above those I'm not + compareBool(isQAingByMe(a), isQAingByMe(b)) || + // Pulls with no external_block above those with external_block + compareBool(!a.getLabel("external_block"), !b.getLabel("external_block")) || + // Pulls with no merge conflicts above those with merge conflicts + compareBool(!a.hasMergeConflicts(), !b.hasMergeConflicts()) || + // Pulls with no QAing label above those with QAing + compareBool(!a.getLabel("QAing"), !b.getLabel("QAing")) || + // Pulls with CR completed above those that need more + compareBool(a.isCrDone(), b.isCrDone()) || + // younger pulls before older pulls + b.created_at.localeCompare(a.created_at) + ); +} + +export function DeployCompare(a: Pull, b: Pull): number { + return ( + // Pulls with no merge conflicts above those with merge conflicts + compareBool(!a.hasMergeConflicts(), !b.hasMergeConflicts()) + ); +} + +export function signatureCompare(a: Signature, b: Signature) { + return ( + // Active before inactive + b.data.active - a.data.active || + // My sigs before others + compareBool( + a.data.user.login == getUser(), + b.data.user.login == getUser() + ) || + // Older before newer + a.data.created_at.localeCompare(b.data.created_at) + ); +} + +export function closedAtCompare(a: Pull, b: Pull) { + const astr = String(a.closed_at); + const bstr = String(b.closed_at); + return astr < bstr ? 1 : astr > bstr ? -1 : 0; +} + +function isQAingByMe(pull: Pull): boolean { + const label = pull.getLabel("QAing"); + return label?.user == getUser(); +} + +function compareBool(a: boolean, b: boolean): number { + return +b - +a; +} diff --git a/frontend/src/theme.tsx b/frontend/src/theme.tsx new file mode 100644 index 00000000..ccd24e53 --- /dev/null +++ b/frontend/src/theme.tsx @@ -0,0 +1,226 @@ +import { extendTheme } from "@chakra-ui/react"; +import "./theme/base.less"; +import "./theme/day_theme.less"; +import "./theme/night_theme.less"; + +export const theme = extendTheme({ + initialColorMode: "light", + useSystemColorMode: false, + components: { + Link: { + baseStyle: { + "&:hover": { + textDecoration: "none", + }, + }, + }, + Column: { + baseStyle: { + border: "1px solid var(--panel-default-border)", + borderRadius: "7px", + "& .column_header": { + color: "var(--panel-default-text)", + cursor: "pointer", + background: "var(--panel-default-background)", + fontSize: "1rem", + }, + "& .pull_count": { + minWidth: "50px", + textAlign: "center", + color: "var(--panel-default-count-text)", + borderLeft: "solid 1px var(--panel-default-count-border)", + background: "var(--panel-default-count-background)", + }, + }, + variants: { + ciBlocked: { + borderColor: "var(--panel-ci-blocked-border)", + "& .column_header": { + background: "var(--panel-ci-blocked-background)", + color: "var(--panel-ci-blocked-text)", + }, + "& .pull_count": { + color: "var(--panel-ci-blocked-count-text)", + borderLeft: "solid 1px var(--panel-ci-blocked-count-border)", + background: "var(--panel-ci-blocked-count-background)", + }, + }, + deployBlocked: { + borderColor: "var(--panel-deploy-blocked-border)", + "& .column_header": { + background: "var(--panel-deploy-blocked-background)", + color: "var(--panel-deploy-blocked-text)", + }, + "& .pull_count": { + color: "var(--panel-deploy-blocked-count-text)", + borderLeft: "solid 1px var(--panel-deploy-blocked-count-border)", + background: "var(--panel-deploy-blocked-count-background)", + }, + }, + ready: { + borderColor: "var(--panel-ready-border)", + "& .column_header": { + background: "var(--panel-ready-background)", + color: "var(--panel-ready-text)", + }, + "& .pull_count": { + color: "var(--panel-ready-count-text)", + borderLeft: "solid 1px var(--panel-ready-count-border)", + background: "var(--panel-ready-count-background)", + }, + }, + closed: { + borderRadius: "7px 0 0 7px", + backgroundColor: "var(--background-color)", + "& .column_header": { + borderRadius: "7px 0 0 0", + }, + }, + }, + }, + StatusGroup: { + baseStyle: { + display: "block", + pos: "relative", + w: "100%", + opacity: 1, + transition: "opacity 0.3s ease-in-out", + borderRadius: "5px", + }, + variants: { + pending: { + bg: "var(--build-state-pending)", + }, + success: { + bg: "var(--build-state-success)", + opacity: 0, + }, + error: { + bg: "var(--build-state-error)", + }, + failure: { + bg: "var(--build-state-failure)", + }, + }, + }, + StatusLink: { + baseStyle: { + borderBottom: "1px solid var(--build-status-link-divider)", + "&:last-child": { + borderBottom: "none", + }, + padding: "5px", + "> span": { + color: "var(--build-status-text)", + marginLeft: "10px", + }, + "&:hover": { + background: "var(--build-status-hover)", + }, + }, + variants: { + pending: { + color: "var(--build-state-pending)", + }, + success: { + color: "var(--build-state-success)", + }, + error: { + color: "var(--build-state-error)", + }, + failure: { + color: "var(--build-state-failure)", + }, + }, + }, + Signatures: { + baseStyle: { + fontSize: 14, + fontWeight: 700, + borderRadius: "3px", + backgroundColor: "var(--status-background)", + borderWidth: "1px", + borderColor: "var(--status-border)", + color: "var(--status-text)", + }, + variants: { + valid: { + backgroundColor: "var(--status-valid-background)", + borderColor: "var(--status-valid-border)", + color: "var(--status-valid-text)", + }, + validMine: { + backgroundColor: "var(--status-valid-mine-background)", + borderColor: "var(--status-valid-mine-border)", + color: "var(--status-valid-mine-text)", + }, + }, + }, + SignatureListItem: { + baseStyle: { + display: "block", + color: "var(--build-status-text)", + borderBottom: "1px solid var(--build-status-link-divider)", + "&:last-child": { + borderBottom: "none", + }, + padding: "5px", + "&:hover": { + background: "var(--build-status-hover)", + }, + }, + }, + PullFlag: { + baseStyle: { + py: "5px", + px: "6px", + lineHeight: "1em", + borderRadius: "3px", + fontSize: "18px", + border: "solid 1px", + }, + variants: { + devBlock: { + color: "var(--tag-dev-blocked)", + backgroundColor: "var(--tag-dev-blocked-background)", + borderColor: "var(--tag-dev-blocked-border)", + }, + draftBlock: { + color: "var(--tag-draft-blocked)", + backgroundColor: "var(--tag-draft-blocked-background)", + borderColor: "var(--tag-draft-blocked-border)", + }, + deployBlock: { + color: "var(--tag-deploy-blocked)", + backgroundColor: "var(--tag-deploy-blocked-background)", + borderColor: "var(--tag-deploy-blocked-border)", + }, + QAing: { + color: "var(--tag-qaing)", + backgroundColor: "var(--tag-qaing-background)", + borderColor: "var(--tag-qaing-border)", + }, + externalBlock: { + color: "var(--tag-externally-blocked)", + backgroundColor: "var(--tag-externally-blocked-background)", + borderColor: "var(--tag-externally-blocked-border)", + }, + cryogenicStorage: { + color: "var(--tag-cryo)", + backgroundColor: "var(--tag-cryo-background)", + borderColor: "var(--tag-cryo-border)", + }, + mergeConflict: { + color: "var(--tag-merge-conflict)", + backgroundColor: "var(--tag-merge-conflict-background)", + borderColor: "var(--tag-merge-conflict-border)", + }, + dependentPR: { + color: "var(--tag-dependent-pr)", + backgroundColor: "var(--tag-dependent-pr-background)", + borderColor: "var(--tag-dependent-pr-border)", + }, + }, + }, + }, +}); diff --git a/frontend/src/theme/base.less b/frontend/src/theme/base.less new file mode 100644 index 00000000..73246c82 --- /dev/null +++ b/frontend/src/theme/base.less @@ -0,0 +1,34 @@ +body.themed { + background-color: var(--background-color); + + --body-max-width: 1200px; + + // Padding around columns + --body-gutter: 24px; + + font: 400 14px Lato; + line-height: 1.5; + + .pull-flag:hover { + box-shadow: 0 0 0 1px; + } +} + +@keyframes highlightPull { + from { + background-color: var(--pull-updated-background); + } +} + +body.themed, +html { + min-height: 100vh; +} + +*:focus { + box-shadow: none !important; +} + +*[data-focus] { + box-shadow: none !important; +} diff --git a/frontend/src/theme/day_theme.less b/frontend/src/theme/day_theme.less new file mode 100644 index 00000000..cd9887be --- /dev/null +++ b/frontend/src/theme/day_theme.less @@ -0,0 +1,184 @@ +body.chakra-ui-light, +body[data-theme="day_theme"] { + @blue: #3683c5; + @green: #6cb644; + @yellow: #e7b049; + @red: #db524b; + + @closed: #cf222e; + @merged: #8250df; + + @white: #ffffff; + @light-grey: #f5f5f5; + @grey: #e7e7e7; + @dark-grey: #555555; + @black: #000000; + + --background-color: @white; + + // Header + --header-background: @light-grey; + --header-border: @grey; + + --theme-selector-text: @dark-grey; + --theme-selector-active: @black; + --theme-selector-inactive: @dark-grey; + + --toggle-border: darken(@grey, 10%); + --toggle-background: @light-grey; + --toggle-background-hover: @grey; + + --brand-color: @dark-grey; + --brand-highlight: @blue; + + --global-indicator-text: @dark-grey; + --global-indicator-text-link: @blue; + --global-indicator-count-text: @blue; + --global-indicator-count-border: @white; + --global-indicator-count-background: @white; + + // Leaderboard + --leaderboard-background: @light-grey; + --leaderboard-border: @grey; + --leaderboard-title-text: @dark-grey; + + --leaderboard-posting-background: @blue; + --leaderboard-posting-border: @blue; + --leaderboard-posting-count: @white; + + --leaderboard-leader-background: @green; + --leaderboard-leader-border: @green; + --leaderboard-leader-count: @white; + + // Pulls + --panel-ci-blocked-text: @black; + --panel-ci-blocked-background: lighten(@yellow, 15%); + --panel-ci-blocked-border: @yellow; + --panel-ci-blocked-count-text: @black; + --panel-ci-blocked-count-background: lighten(@yellow, 5%); + --panel-ci-blocked-count-border: darken(@yellow, 10%); + + --panel-deploy-blocked-text: @black; + --panel-deploy-blocked-background: lighten(@red, 15%); + --panel-deploy-blocked-border: @red; + --panel-deploy-blocked-count-text: @black; + --panel-deploy-blocked-count-background: lighten(@red, 5%); + --panel-deploy-blocked-count-border: darken(@red, 10%); + + --panel-ready-text: @black; + --panel-ready-background: lighten(@green, 15%); + --panel-ready-border: @green; + --panel-ready-count-text: @black; + --panel-ready-count-background: lighten(@green, 5%); + --panel-ready-count-border: darken(@green, 10%); + + --panel-default-text: @black; + --panel-default-background: @light-grey; + --panel-default-border: darken(@light-grey, 15%); + --panel-default-count-text: @black; + --panel-default-count-background: darken(@light-grey, 15%); + --panel-default-count-border: darken(@light-grey, 15%); + + --pull-title: @dark-grey; + --pull-number: @black; + --pull-background: @white; + --pull-background-hover: @light-grey; + --pull-separator: darken(@light-grey, 10%); + --pull-updated-background: #fff3a6; + --pull-merged: @merged; + --pull-closed: @closed; + + // Build statuses + --build-state-pending: @grey; + --build-state-failure: @yellow; + --build-state-error: @red; + --build-state-success: @green; + --build-status-link-divider: rgba(128, 128, 128, 0.2); + --build-status-hover: var(--build-status-link-divider); + --build-status-text: @dark-grey; + + --tag-default-border: darken(@light-grey, 10%); + --tag-default-background: @white; + + --tag-current-milestone: @blue; + --tag-current-milestone-border: var(--tag-default-border); + --tag-current-milestone-background: var(--tag-default-background); + + --tag-past-milestone: @red; + --tag-past-milestone-border: var(--tag-default-border); + --tag-past-milestone-background: var(--tag-default-background); + + --tag-dev-blocked: @yellow; + --tag-dev-blocked-border: var(--tag-default-border); + --tag-dev-blocked-background: var(--tag-default-background); + + --tag-draft-blocked: @blue; + --tag-draft-blocked-border: var(--tag-default-border); + --tag-draft-blocked-background: var(--tag-default-background); + + --tag-deploy-blocked: @red; + --tag-deploy-blocked-border: var(--tag-default-border); + --tag-deploy-blocked-background: var(--tag-default-background); + + --tag-qaing: @green; + --tag-qaing-mine: @blue; + --tag-qaing-border: var(--tag-default-border); + --tag-qaing-background: var(--tag-default-background); + + --tag-externally-blocked: @red; + --tag-externally-blocked-border: var(--tag-default-border); + --tag-externally-blocked-background: var(--tag-default-background); + + --tag-cryo: var(--tag-default-background); + --tag-cryo-border: var(--tag-default-border); + --tag-cryo-background: @blue; + + --tag-merge-conflict: @yellow; + --tag-merge-conflict-border: var(--tag-default-border); + --tag-merge-conflict-background: var(--tag-default-background); + + --tag-dependent-pr: @red; + --tag-dependent-pr-border: var(--tag-default-border); + --tag-dependent-pr-background: var(--tag-default-background); + + --user-icon: @blue; + + // Pull age + --age-1: @green; + --age-2: @yellow; + --age-3: @red; + + // CR and QA boxes + --status-background: @white; + --status-border: darken(@light-grey, 10%); + --status-text: @dark-grey; + + --status-valid-background: lighten(@green, 35%); + --status-valid-border: @green; + --status-valid-text: @green; + + --status-valid-mine-background: lighten(@blue, 35%); + --status-valid-mine-border: @blue; + --status-valid-mine-text: @blue; + + // Signatures (CR / QA) + --signature-default: darken(@grey, 10%); + --signature-valid: @green; + --signature-valid-mine: @blue; + --signature-invalid: @yellow; + --signature-invalid-mine: @red; + + --copy-branch: darken(@grey, 25%); + --copy-branch-hover: darken(@blue, 10%); + + --refresh: darken(@grey, 25%); + --refresh-background: @white; + --refresh-hover: @blue; + + --participants-without-me: @grey; + --participants-including-me: @blue; + + // Lines Changed + --additions: darken(@green, 10%); + --deletions: @red; +} diff --git a/frontend/src/theme/night_theme.less b/frontend/src/theme/night_theme.less new file mode 100644 index 00000000..4f29943c --- /dev/null +++ b/frontend/src/theme/night_theme.less @@ -0,0 +1,193 @@ +body.chakra-ui-dark, +body[data-theme="night_theme"] { + @blue: #3683c5; + @green: #67ba76; + @yellow: #d59a6a; + @red: #e66873; + + @dark-green: #334c3e; + @dark-yellow: #4b4139; + @dark-red: #50343c; + + @white: #f5f5f5; + @light-grey: #abb2c0; + @grey: #777777; + @dark-grey: #2d3239; + @steel: #20242b; + @black: #171a1f; + + --background-color: @steel; + + // Header + --header-background: @black; + --header-border: @black; + + --theme-selector-text: @light-grey; + --theme-selector-active: @white; + --theme-selector-inactive: @grey; + + --toggle-border: @light-grey; + --toggle-background: @steel; + --toggle-background-hover: @dark-grey; + + --brand-color: @light-grey; + --brand-highlight: @blue; + + --global-indicator-text: @light-grey; + --global-indicator-text-link: @blue; + --global-indicator-count-text: @white; + --global-indicator-count-border: @steel; + --global-indicator-count-background: @steel; + + // Leaderboard + --leaderboard-background: @black; + --leaderboard-border: @black; + --leaderboard-title-text: @light-grey; + + --leaderboard-posting-background: darken(@blue, 30%); + --leaderboard-posting-border: darken(@blue, 30%); + --leaderboard-posting-count: @blue; + + --leaderboard-leader-background: @dark-green; + --leaderboard-leader-border: @dark-green; + --leaderboard-leader-count: @green; + + // Pulls + --panel-ci-blocked-text: @yellow; + --panel-ci-blocked-background: @dark-yellow; + --panel-ci-blocked-border: @dark-yellow; + --panel-ci-blocked-count-text: @white; + --panel-ci-blocked-count-background: #906e51; + --panel-ci-blocked-count-border: @dark-yellow; + + --panel-deploy-blocked-text: @red; + --panel-deploy-blocked-background: @dark-red; + --panel-deploy-blocked-border: @dark-red; + --panel-deploy-blocked-count-text: @white; + --panel-deploy-blocked-count-background: #9b4d57; + --panel-deploy-blocked-count-border: @dark-red; + + --panel-ready-text: @green; + --panel-ready-background: @dark-green; + --panel-ready-border: @dark-green; + --panel-ready-count-text: @white; + --panel-ready-count-background: #569262; + --panel-ready-count-border: @dark-green; + + --panel-default-text: #a7afbd; + --panel-default-background: @dark-grey; + --panel-default-border: @dark-grey; + --panel-default-count-text: @white; + --panel-default-count-background: #3a4148; + --panel-default-count-border: @dark-grey; + + --pull-title: @light-grey; + --pull-number: @white; + --pull-background: @steel; + --pull-background-hover: @black; + + --pull-separator: lighten(@steel, 10%); + + // Build statuses + --build-state-pending: @grey; + --build-state-failure: @yellow; + --build-state-error: @red; + --build-state-success: @green; + --build-status-link-divider: rgba(128, 128, 128, 0.2); + --build-status-hover: var(--build-status-link-divider); + --build-status-text: @light-grey; + + --tag-default-border: @dark-grey; + --tag-default-background: @dark-grey; + + --tag-current-milestone: @blue; + --tag-current-milestone-border: var(--tag-default-border); + --tag-current-milestone-background: var(--tag-default-background); + + --tag-past-milestone: @red; + --tag-past-milestone-border: var(--tag-default-border); + --tag-past-milestone-background: var(--tag-default-background); + + --tag-dev-blocked: @yellow; + --tag-dev-blocked-border: var(--tag-default-border); + --tag-dev-blocked-background: var(--tag-default-background); + + --tag-draft-blocked: @blue; + --tag-draft-blocked-border: var(--tag-default-border); + --tag-draft-blocked-background: var(--tag-default-background); + + --tag-deploy-blocked: @red; + --tag-deploy-blocked-border: var(--tag-default-border); + --tag-deploy-blocked-background: var(--tag-default-background); + + --tag-qaing: @green; + --tag-qaing-mine: @blue; + --tag-qaing-border: var(--tag-default-border); + --tag-qaing-background: var(--tag-default-background); + + --tag-externally-blocked: @red; + --tag-externally-blocked-border: var(--tag-default-border); + --tag-externally-blocked-background: var(--tag-default-background); + + --tag-merge-conflict: @yellow; + --tag-merge-conflict-border: var(--tag-default-border); + --tag-merge-conflict-background: var(--tag-default-background); + + --tag-dependent-pr: @red; + --tag-dependent-pr-border: var(--tag-default-border); + --tag-dependent-pr-background: var(--tag-default-background); + + --user-icon: @blue; + + // Pull age + --age-1: @green; + --age-2: @yellow; + --age-3: @red; + + // CR and QA boxes + --status-background: @dark-grey; + --status-border: @dark-grey; + --status-text: @light-grey; + + --status-valid-background: @dark-green; + --status-valid-border: @dark-green; + --status-valid-text: @green; + + --status-valid-mine-background: darken(@blue, 30%); + --status-valid-mine-border: darken(@blue, 30%); + --status-valid-mine-text: @blue; + + // Signatures (CR / QA) + --signature-default: @steel; + --signature-valid: @green; + --signature-valid-mine: @blue; + --signature-invalid: @yellow; + --signature-invalid-mine: @red; + + --refresh: darken(@grey, 15%); + --refresh-background: @steel; + --refresh-hover: @blue; + + --participants-without-me: darken(@grey, 15%); + --participants-including-me: @blue; + + --copy-branch: darken(@grey, 15%); + --copy-branch-hover: @blue; + + // Lines Changed + --additions: @green; + --deletions: lighten(@red, 5%); + + i.fa.fa-sun-o:hover { + color: @yellow !important; + } + + .panel-title { + font-weight: 700; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + } + + .pull-title .pull-number { + font-weight: 400; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 00000000..bad323fe --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,130 @@ +export enum PullState { + open = "open", + closed = "closed", +} + +export type DateString = string; + +export enum StatusState { + error = "error", + pending = "pending", + success = "success", + failure = "failure", +} + +export enum CommentSource { + comment = "comment", + review = "review", +} + +export enum SignatureType { + CR = "CR", + QA = "QA", + dev_block = "dev_block", + deploy_block = "deploy_block", +} + +export interface SignatureUser { + id: number; + login: string; +} + +export interface RepoSpec { + requiredStatuses?: string[]; + ignoredStatuses?: string[]; + hideByDefault?: boolean; + name: string; +} + +export interface Label { + title: string; + number: number; + repo: string; + user: string; + created_at: DateString; +} + +export interface Signature { + data: { + repo: string; + number: number; + user: SignatureUser; + type: SignatureType; + created_at: DateString; + active: number; + comment_id: number; + source_type: CommentSource; + }; +} + +export interface SignatureGroup { + // Contains all signatures that are active + current: Signature[]; + // Contains all signatures that are inactive from users without signatures in current + old: Signature[]; +} + +export interface CommitStatus { + data: { + sha: string; + target_url: string | null; + description: string; + state: StatusState; + context: string; + started_at: number | null; + completed_at: number | null; + }; +} + +export class PullData { + repo: string; + repoSpec: RepoSpec | null; + number: number; + state: PullState; + title: string; + body: string; + draft: boolean; + received_at: Date | null; + created_at: DateString; + updated_at: DateString; + closed_at: DateString | null; + mergeable: boolean | null; + merged_at: DateString | null; + difficulty: number | null; + additions: number | null; + deletions: number | null; + milestone: { + title: string | null; + due_on: string | null; + }; + head: { + ref: string; + sha: string; + repo: { + owner: { + login: string; + }; + }; + }; + base: { + ref: string; + }; + user: { + login: string; + }; + cr_req: number; + qa_req: number; + status: { + qa_req: number; + cr_req: number; + allQA: Signature[]; + allCR: Signature[]; + dev_block: Signature[]; + deploy_block: Signature[]; + commit_statuses: CommitStatus[]; + }; + labels: Label[]; + participants: string[]; + assignees: string[]; + requested_reviewers: string[]; +} diff --git a/frontend/src/use-url-state.ts b/frontend/src/use-url-state.ts new file mode 100644 index 00000000..50fb556d --- /dev/null +++ b/frontend/src/use-url-state.ts @@ -0,0 +1,118 @@ +import { useState, useCallback, useEffect, useMemo } from "react"; + +type state = string | null; +type stateSetter = (state: state) => void; +type stringSetter = (state: string) => void; + +type useUrlStateReturn = [string, stateSetter]; + +type HistoryState = Record; + +/** + * like useState(), but the state is stored in the url as a query param. + * Loads it from a query param on page refresh and updates the url as the state + * changes. + * paramName is expected not to change over the life of the component. + */ +export function useUrlState( + paramName: string, + paramDefault: string +): useUrlStateReturn { + const [state, setState] = useState(() => + parseStateFromUrl(paramName, paramDefault) + ); + + // Watch for url state changes (back button) only once per component. + useEffect(() => watchForPopstate(paramName, setState, paramDefault), []); + + // Wrap setState so it pushes history and transforms the url + const setUrlState = useCallback((newState: state) => { + // Turn null into the default so state is never null + setState(newState || paramDefault); + // Turn default into null so that default values disappear from the url + pushStateToUrl(paramName, newState === paramDefault ? null : newState); + }, []); + + return [state, setUrlState]; +} + +function parseStateFromUrl(paramName: string, paramDefault: string): string { + const url = new URL(window.location.href); + return url.searchParams.get(paramName) ?? paramDefault; +} + +function watchForPopstate( + paramName: string, + setState: stringSetter, + paramDefault: string +) { + const handler = (event: PopStateEvent) => { + const stateFromHistory = event.state ? event.state[paramName] : undefined; + setState(stateFromHistory || parseStateFromUrl(paramName, paramDefault)); + }; + window.addEventListener("popstate", handler); + return () => window.removeEventListener("popstate", handler); +} + +/** + * Update the current url to match the new state and push the new historyState + * on the stack + */ +function pushStateToUrl(paramName: string, newState: state): void { + const url = new URL(window.location.href); + if (newState) { + url.searchParams.set(paramName, newState); + } else { + url.searchParams.delete(paramName); + } + history.pushState(urlToStateObject(url), "", url.toString()); +} + +function urlToStateObject(url: URL): HistoryState { + const historyState: HistoryState = {}; + url.searchParams.forEach((value, key) => { + historyState[key] = value; + }); + return historyState; +} + +/** + * Just like useUrlState(), but typed for storing a boolean + */ +export function useBoolUrlState( + paramName: string, + paramDefault: boolean +): [boolean, () => void] { + const [state, setState] = useUrlState(paramName, paramDefault ? "1" : "0"); + + const toggleBoolState = useCallback(() => { + const newState = state == "0" ? "1" : "0"; + console.log(`Toggling ${paramName} from ${state} to ${newState}`); + setState(newState); + }, [state]); + + return [state === "1", toggleBoolState]; +} + +/** + * Just like useUrlState(), but typed for storing an array of strings + */ +export function useArrayUrlState( + paramName: string, + paramDefault: string[] +): [string[], (state: string[]) => void] { + const [state, setState] = useUrlState(paramName, paramDefault.join(",")); + + const setArrayState = useCallback( + (newState: string[]) => { + setState(newState.join(",")); + }, + [setState] + ); + + // useMemo() is here so that downstream callers can depend on the array + // staying the same referentially if the state hasn't changed. + const valueArray = useMemo(() => (state ? state.split(",") : []), [state]); + + return [valueArray, setArrayState]; +} diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts new file mode 100644 index 00000000..36baabdf --- /dev/null +++ b/frontend/src/utils.ts @@ -0,0 +1,95 @@ +import { DateString, PullData, StatusState, CommitStatus } from "./types"; + +export function actionMessage( + action: string, + date: DateString | null, + user: string +): string { + return date + ? `${action} on ${formatDate(date)} by ${user}` + : `${action} by ${user}`; +} + +export function formatDate(date: DateString) { + return new Date(date).toLocaleDateString([], { + month: "short", + day: "numeric", + }); +} + +export function toDateString(date: Date): DateString { + return date.toISOString(); +} + +export function userProfileUrl(username: string): string { + const debottedName = username.replace(/\[bot\]$/, ""); + const safeUsername = encodeURIComponent(debottedName); + return `https://github.com/${safeUsername}`; +} + +export const dummyPulls: PullData[] = fakeCIStatuses( + (process.env.DUMMY_PULLS || []) as PullData[] +); +export function hasDummyPulls() { + return dummyPulls.length > 0; +} + +export function newTab(url: string) { + window.open(url, "_blank"); +} + +function fakeCIStatuses(pulls: PullData[]) { + const fakeContexts = [ + "psalm", + "deploy", + "unit-tests", + "api", + "integration", + "browser", + "bundle-analysis", + ]; + pulls.forEach((pull) => { + pull.status.commit_statuses = fakeContexts.map((context) => { + const now = unix(); + const startedAt = now - Math.random() * 120 * 60; + const duration = Math.random() * 10 * 60; + const completedAt = startedAt + duration; + return { + data: { + sha: "fake sha", + target_url: "https://example.com", + description: context, + context: context, + state: completedAt > now ? StatusState.pending : finishedState(), + started_at: startedAt, + completed_at: completedAt > now ? null : completedAt, + }, + }; + }); + }); + return pulls; +} + +function finishedState() { + const r = Math.random(); + return r < 0.01 + ? StatusState.error + : r < 0.03 + ? StatusState.failure + : StatusState.success; +} + +function unix() { + return Date.now() / 1000; +} + +export function useDurationMinutes(status: CommitStatus) { + const { started_at, completed_at } = status.data; + if (!started_at) { + return null; + } + + const completed = completed_at || Date.now() / 1000; + const min = (completed - started_at) / 60; + return min > 0 ? min : null; +} diff --git a/frontend/test/named-pulls.ts b/frontend/test/named-pulls.ts new file mode 100644 index 00000000..542ce12e --- /dev/null +++ b/frontend/test/named-pulls.ts @@ -0,0 +1,532 @@ +import { getUser } from "../src/page-context"; +import { PullData, SignatureType } from "../src/types"; +import { label, status, sig, daysAgo, pullData } from "./pull-data-parts"; + +export const AgePulls = [ + pullData({ + title: "Brand new pull", + }), + pullData({ + title: "4 day old pull", + created_at: daysAgo(4), + }), + pullData({ + title: "20 day old pull", + created_at: daysAgo(20), + }), +]; + +export const LinesChanged = [ + pullData({ + title: "Pull card with # of lines changed", + additions: 248, + deletions: 50, + }), +]; + +export const UnfulfilledRequirements = [ + pullData({ + title: "No Requirements", + cr_req: 0, + qa_req: 0, + }), + pullData({ + title: "3 CR Required but no QA", + cr_req: 3, + qa_req: 0, + }), + pullData({ + title: "3 CR Required and 2 QA", + cr_req: 3, + qa_req: 2, + }), +]; + +const activeCR = sig(SignatureType.CR, true); +const activeQA = sig(SignatureType.QA, true); +export const PartialRequirements = [ + pullData({ + title: "1 CR and 1 QA", + status: { + allQA: [activeQA], + allCR: [activeCR], + }, + }), + pullData({ + title: "1 CR and 1 QA, but higher requirements", + cr_req: 3, + qa_req: 2, + status: { + allQA: [activeQA], + allCR: [activeCR], + }, + }), + pullData({ + title: "1 CR and 1 QA, but no requirements", + cr_req: 0, + qa_req: 0, + status: { + allQA: [activeQA], + allCR: [activeCR], + }, + }), +]; + +export const Signatures = [ + pullData({ + title: "1 Out-of-date CR and 1 out-of-date QA by the same user", + status: { + allQA: [sig(SignatureType.QA, false, "user1")], + allCR: [sig(SignatureType.CR, false, "user1")], + }, + }), + pullData({ + title: + "1 CR and 1 out-of-date CR by the same user. 1 QA and 1 out-of-date QA by the same user", + status: { + allQA: [ + sig(SignatureType.QA, true, "user1"), + sig(SignatureType.QA, false, "user1"), + ], + allCR: [ + sig(SignatureType.CR, false, "user1"), + sig(SignatureType.CR, true, "user1"), + ], + }, + }), + pullData({ + title: "Many active CRs and inactive CRs by the same user", + status: { + allCR: [ + sig(SignatureType.CR, false, "user1"), + sig(SignatureType.CR, false, "user1"), + sig(SignatureType.CR, false, "user1"), + sig(SignatureType.CR, false, "user1"), + sig(SignatureType.CR, false, "user1"), + sig(SignatureType.CR, true, "user1"), + sig(SignatureType.CR, true, "user1"), + sig(SignatureType.CR, true, "user1"), + sig(SignatureType.CR, true, "user1"), + sig(SignatureType.CR, true, "user1"), + ], + }, + }), +]; + +export const ManySignatures = [ + pullData({ + title: + "1 out-of-date QA by me, 1 active and 3 out of date CRs, with one by me", + status: { + allQA: [sig(SignatureType.QA, false, "user1")], + allCR: [ + sig(SignatureType.CR, true, "user8"), + sig(SignatureType.CR, false, "user9"), + sig(SignatureType.CR, false, "user10"), + sig(SignatureType.CR, false, getUser()), + ], + }, + }), + pullData({ + title: + "3 active QAs, 5 active CRs and 5 out of date CRs by different users", + status: { + allQA: [ + sig(SignatureType.QA, true, "user8"), + sig(SignatureType.QA, true, "user9"), + sig(SignatureType.QA, true, "user10"), + ], + allCR: [ + sig(SignatureType.CR, false, "user1"), + sig(SignatureType.CR, false, "user2"), + sig(SignatureType.CR, false, "user3"), + sig(SignatureType.CR, false, "user4"), + sig(SignatureType.CR, false, "user5"), + sig(SignatureType.CR, true, "user6"), + sig(SignatureType.CR, true, "user7"), + sig(SignatureType.CR, true, "user8"), + sig(SignatureType.CR, true, "user9"), + sig(SignatureType.CR, true, "user10"), + ], + }, + }), + pullData({ + title: "1 active CR and 5 out of date CRs by different users", + status: { + allQA: [], + allCR: [ + sig(SignatureType.CR, false, "user1"), + sig(SignatureType.CR, false, "user2"), + sig(SignatureType.CR, false, "user3"), + sig(SignatureType.CR, false, "user4"), + sig(SignatureType.CR, false, "user5"), + sig(SignatureType.CR, true, "user6"), + ], + }, + }), +]; +const activeCR2 = sig(SignatureType.CR, true); +const activeQA2 = sig(SignatureType.QA, true); +export const FulfilledRequirements = [ + pullData({ + title: "1 CR and 1 QA with a 1,1 requirement", + cr_req: 1, + qa_req: 1, + status: { + allQA: [activeQA], + allCR: [activeCR], + }, + }), + pullData({ + title: "2 CR and 2 QA but 2,1 is required", + cr_req: 2, + qa_req: 1, + status: { + allQA: [activeQA, activeQA2], + allCR: [activeCR, activeCR2], + }, + }), + pullData({ + title: + "2 active and 1 out of date CR where only 2 are required. QA has 2 active, 1 out of date and 1 required", + cr_req: 2, + qa_req: 1, + status: { + allQA: [activeQA, activeQA2, sig(SignatureType.QA, false)], + allCR: [activeCR, activeCR2, sig(SignatureType.CR, false)], + }, + }), +]; + +export const FewStatuses = [ + pullData({ + title: "Pending status with no log url", + status: { + commit_statuses: [status("pending", null)], + }, + }), + pullData({ + title: "One error commit status", + status: { + commit_statuses: [status("error")], + }, + }), + pullData({ + title: "One successful and one failed commit status", + status: { + commit_statuses: [status("success"), status("failure")], + }, + }), +]; + +const repoSpec = { name: "some/repo", requiredStatuses: ["unit-tests"] }; +export const RequiredStatuses = [ + pullData({ + repoSpec, + title: "Required 'unit-tests' status with it being a failure", + status: { + commit_statuses: [status("failure", null, "unit-tests")], + }, + }), + pullData({ + repoSpec, + title: "Required 'unit-tests' status with a second non-required status", + status: { + commit_statuses: [status("success")], + }, + }), + pullData({ + repoSpec, + title: "Required 'unit-tests' status with no actual statuses", + status: { + commit_statuses: [], + }, + }), +]; + +export const ManyStatuses = [ + pullData({ + title: "Three commit statuses", + status: { + commit_statuses: [status("pending"), status("success"), status("error")], + }, + }), + pullData({ + title: "Six commit statuses", + status: { + commit_statuses: [ + status("error"), + status("failure"), + status("success"), + status("pending"), + status("success"), + status("error"), + ], + }, + }), + pullData({ + title: "Ten commit statuses", + status: { + commit_statuses: [ + status("success"), + status("error"), + status("success"), + status("pending"), + status("success"), + status("failure"), + status("success"), + status("failure"), + status("success"), + status("success"), + ], + }, + }), +]; + +const devBlock = sig(SignatureType.dev_block, true); +const deployBlock = sig(SignatureType.deploy_block, true); +export const Blocked = [ + pullData({ + title: "Deploy Blocked", + status: { + deploy_block: [deployBlock], + }, + }), + pullData({ + title: "Dev Blocked", + status: { + dev_block: [devBlock], + }, + }), + pullData({ + title: "Merge Conflict(s)", + mergeable: false, + }), + pullData({ + title: "Awaiting a merge", + base: { + ref: "not-main", + }, + }), +]; + +export const Milestones = [ + pullData({ + title: "With a future Milestone", + milestone: { + title: "Some big project", + due_on: daysAgo(-10), + }, + }), + pullData({ + title: "With a past Milestone", + milestone: { + title: "Some overdue project", + due_on: daysAgo(10), + }, + }), +]; + +export const Labels = [ + pullData({ + title: "With a QAing Label applied 1 hour ago", + labels: [ + label({ + title: "QAing", + user: "someUser", + created_at: daysAgo(1 / 24), + }), + ], + }), + pullData({ + title: "With an external_block Label applied 1 day ago", + labels: [ + label({ + title: "external_block", + user: "someOtherUser", + created_at: daysAgo(1), + }), + ], + }), + pullData({ + title: 'With a "Cryogenic Storage" Label applied 10 days ago', + labels: [ + label({ + title: "Cryogenic Storage", + user: "someCryoUser", + created_at: daysAgo(10), + }), + ], + }), +]; + +export const Draft = [ + pullData({ + title: "Draft", + draft: true, + }), +]; + +export const MyOwn = [ + pullData({ + title: "Pull Created By Me", + user: { + login: getUser(), + }, + }), + pullData({ + title: "With an up-to-date CR and a QA by Me", + status: { + allQA: [sig(SignatureType.QA, true, getUser())], + allCR: [sig(SignatureType.CR, true, getUser())], + }, + }), + pullData({ + title: "With an out-of-date CR and a QA by Me", + status: { + allQA: [sig(SignatureType.QA, false, getUser())], + allCR: [sig(SignatureType.CR, false, getUser())], + }, + }), +]; + +export const Participants = [ + pullData({ + number: 1, + title: "No participants", + cr_req: 0, + participants: [], + }), + pullData({ + number: 2, + title: "One participant", + cr_req: 0, + participants: ["Other"], + }), + pullData({ + number: 3, + title: "Multiple participants", + cr_req: 0, + participants: ["User2", "User3"], + }), + pullData({ + number: 4, + title: "Only me participating", + cr_req: 0, + participants: [getUser()], + }), + pullData({ + number: 5, + title: "multiple participants (including me)", + cr_req: 0, + participants: ["User2", "User3", getUser()], + }), +]; + +export const Authors = [ + pullData({ + number: 1, + title: "Single author (not me)", + cr_req: 0, + user: { login: "author1" }, + }), + pullData({ + number: 2, + title: "Author plus two assignees (none is me) — hover lists all", + cr_req: 0, + user: { login: "author1" }, + assignees: ["assignee1", "assignee2"], + }), + pullData({ + number: 3, + title: "Authored by me", + cr_req: 0, + user: { login: getUser() }, + }), + pullData({ + number: 4, + title: "Assigned to me (I'm an assignee, not the author)", + cr_req: 0, + user: { login: "author1" }, + assignees: [getUser()], + }), +]; + +export const ReviewRequests = [ + pullData({ + number: 1, + title: "Review requested from me", + cr_req: 1, + requested_reviewers: [getUser()], + }), + pullData({ + number: 1, + title: "Review requested from me", + cr_req: 1, + requested_reviewers: [getUser()], + status: { + allQA: [], + allCR: [sig(SignatureType.CR, true, getUser())], + }, + }), + pullData({ + number: 2, + title: "Review requested from me and another", + cr_req: 1, + requested_reviewers: [getUser(), "User 2"], + status: { + allQA: [], + allCR: [sig(SignatureType.CR, true, "User 2")], + }, + }), + pullData({ + number: 3, + title: "Review requested from someone else", + cr_req: 1, + requested_reviewers: ["User2"], + }), +]; + +export const KitchenSink = [ + pullData({ + title: "Pull With Lots of flags and such and a really long title", + user: { + login: getUser(), + }, + base: { + ref: "not-main", + }, + additions: 120, + deletions: 20, + mergeable: false, + draft: true, + cr_req: 3, + qa_req: 2, + status: { + deploy_block: [deployBlock], + dev_block: [devBlock], + commit_statuses: [ + status("success"), + status("error"), + status("success"), + status("pending"), + status("success"), + status("failure"), + status("success"), + status("failure"), + status("success"), + status("success"), + ], + }, + labels: [ + label({ + title: "QAing", + user: "someUser", + created_at: daysAgo(1 / 24), + }), + ], + participants: ["other person", getUser()], + assignees: ["coAuthor"], + requested_reviewers: [getUser()], + }), +]; diff --git a/frontend/test/pull-card-demo.tsx b/frontend/test/pull-card-demo.tsx new file mode 100644 index 00000000..07031e1d --- /dev/null +++ b/frontend/test/pull-card-demo.tsx @@ -0,0 +1,86 @@ +import { Pull } from "../src/pull"; +import { PullData } from "../src/types"; +import { ChakraProvider, Box, HStack, Heading } from "@chakra-ui/react"; +import { + AgePulls, + LinesChanged, + UnfulfilledRequirements, + PartialRequirements, + FulfilledRequirements, + Signatures, + ManySignatures, + FewStatuses, + RequiredStatuses, + ManyStatuses, + Blocked, + Milestones, + Labels, + Draft, + MyOwn, + Authors, + ReviewRequests, + Participants, + KitchenSink, +} from "./named-pulls"; +import { PullCard } from "../src/pull-card"; +import { render } from "react-dom"; +import { theme } from "../src/theme"; + +const root = document.createElement("div"); +document.body.appendChild(root); + +function PullCardDemo() { + return ( + + + + + + + + + + + + + + + + + + + + + + ); +} + +function Row({ title, pullDatas, linesChanged }: { title: string; pullDatas: PullData[]; linesChanged?: boolean }) { + return ( + <> + + {title} + + {pullDatas.map((pullData, i) => ( + + + + ))} + + + + ); +} + +render(, root); diff --git a/frontend/test/pull-data-parts.ts b/frontend/test/pull-data-parts.ts new file mode 100644 index 00000000..5ead8a1a --- /dev/null +++ b/frontend/test/pull-data-parts.ts @@ -0,0 +1,145 @@ +import { + PullData, + Signature, + SignatureType, + StatusState, + CommitStatus, + Label, +} from "../src/types"; + +const repo = "iFixit/ifixit"; + +export const head = { + ref: "some-branch-name", + sha: "ee18a55031b8b1e86dbe240552dfe86f344559d7", + repo: { + owner: { + login: "iFixit", + }, + }, +}; + +export function daysAgo(days: number): string { + return new Date(Date.now() - days * 86400 * 1000).toString(); +} + +export function label(label: Partial
+ + } + def="CI: a proportional bar, red = failing, gray-blue = still running. Passed checks show nothing at rest — no news is good news; hover to reveal the green and the per-check list" + /> diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index ae35e652..20883850 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -595,35 +595,67 @@ function RowActionsKebab({ } /** - * The weight meter as a filter toggle: click it to add/remove that size class - * from the session Weight filter — the same bucket WeightFilter's own - * checkboxes drive, just row-initiated. Falls back to the plain, - * non-interactive meter when no callback is wired up (e.g. a lens that - * hasn't threaded RowOptions.onWeightToggle). + * The weight chip as both a filter toggle and a hover preview: click adds/ + * removes that size class from the session Weight filter — the same bucket + * WeightFilter's own checkboxes drive, just row-initiated — while hovering + * previews the exact +/− diff size the letter is standing in for. Falls back + * to a plain, non-interactive trigger (still inside the same popover) when no + * callback is wired up (e.g. a lens that hasn't threaded + * RowOptions.onWeightToggle). */ function WeightChip({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { - const meter = ; + const meter = ; const onWeightToggle = opts.onWeightToggle; - if (!onWeightToggle) return meter; const key = pull.sizeKnown ? pull.weight.toLowerCase() : 'unknown'; + const d = pull.data; return ( - + ) : ( + {meter} + ) + } > - - {meter} + + review effort: {WEIGHT_WORD[pull.weight]} + + {pull.sizeKnown && ( + + + + )} + + {pull.sizeKnown ? 'from diff size' : 'size estimated'} - +
); } /** - * The metric rail every row ends on, in one order everywhere: how heavy to - * review, then the CR and QA sign-off pips, then age. Right-anchored and + * The metric rail every row ends on, in one order everywhere: CI, then the CR + * and QA sign-off pips, then how heavy to review — weight is the rightmost + * anchor now, the "can I fit this in" scan column reviewers hunt first. Age + * moved to the meta line (see RowImpl): it's a fact about the pull, not a + * per-row action like the rest of this rail. Right-anchored and * fixed-geometry, so it reads as vertical columns down any lens. Raised above * the card's click layer so the sign-off popovers still open. */ @@ -640,39 +672,40 @@ function MetricRail({ const me = opts.me; return ( - - - - + + {/* weight rides UNDER the whole sign-off section (CR + QA) as a + ratio strip — it's "how heavy is this review", both halves, not + its own rail slot. The shared extent normalizes the track length + and gives the doubling fill real resolution. */} + + + + + + + ); } diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 97755675..4f5110ad 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -1,7 +1,26 @@ +import { useState } from 'react'; import type { ButtonHTMLAttributes, KeyboardEvent as ReactKeyboardEvent } from 'react'; -import { ROT_DAYS, STARVE_DAYS, type Status, type Weight, weightRank } from '../model/status'; -import type { Signature } from '../types'; -import { ago, epoch, githubUrl, loginHue, shortRepo, signatureUrl } from '../format'; +import { + type DerivedPull, + headStatuses, + ROT_DAYS, + STARVE_DAYS, + type Status, + type Weight, + weightRank, +} from '../model/status'; +import type { CommitStatus, Signature } from '../types'; +import { + ago, + epoch, + githubAvatarUrl, + githubProfileUrl, + githubUrl, + loginHue, + n, + shortRepo, + signatureUrl, +} from '../format'; import { getSettings } from '../settings'; import { Popover } from './Popover'; @@ -169,59 +188,233 @@ const WEIGHT_WORD: Record = { L: 'heavy', XL: 'very heavy', }; -// green for the cheap ones, gray at medium, amber then red as effort climbs — -// the same light→heavy read the old XS–XL colors carried, now as fill height -const WEIGHT_FILL = ['var(--ok)', 'var(--ok)', 'var(--ink-3)', 'var(--warn)', 'var(--bad)']; /** - * Review effort as a five-segment meter: how heavy this is to review, filled - * by weight class and color-ramped. Always present, so the rightmost column - * of every row answers "can I fit this in the time I have" at a glance. A - * cheap prior from diff size; humans override by reading (dimmed when the - * wire didn't send additions/deletions). + * Review effort as a horizontal RATIO strip under the whole sign-off + * section (CR and QA — weight is how heavy the REVIEW is, both halves), not + * its own rail slot. The long shared extent gives the exponential fill real + * resolution, and the fill doubles per class — 6/13/25/50/100% — because review + * effort roughly doubles per class, so the lengths separate honestly without + * color carrying anything. Faded whole when the wire didn't send a size; the + * hover popover has the word and exact +/− lines. */ -export function WeightMeter({ weight, known = true }: { weight: Weight; known?: boolean }) { - const fill = weightRank(weight) + 1; - const color = WEIGHT_FILL[weightRank(weight)]; +export function WeightMeter({ + weight, + known = true, + wide = false, +}: { + weight: Weight; + known?: boolean; + /** stretch the track across the container (the rail's whole CR+QA cell); + * default is a fixed 36px track for standalone uses (deal card, legend) */ + wide?: boolean; +}) { + const rank = weightRank(weight); const word = WEIGHT_WORD[weight]; const label = known ? `review effort: ${word}` : `review effort: ${word} (size estimated)`; + const ratio = [6, 13, 25, 50, 100][rank]; return ( - {[0, 1, 2, 3, 4].map(i => ( - - ))} + ); } /** - * The concrete diff size beside the abstract weight meter: +added −deleted, - * GitHub's green/red, so the exact number is there when the five-segment - * gauge isn't precise enough. Hidden when the wire didn't send a size. + * The concrete diff size beside the abstract weight letter: +added −deleted, + * so the exact number is there when the letter chip isn't precise enough. + * Neutral ink, not GitHub's green/red: a line count is a routine metric on + * every healthy PR, and painting it with the broken/done hues taught the eye + * to ignore red — the glyphs already say which side is which. Hidden when the + * wire didn't send a size. */ export function DiffSize({ additions, deletions }: { additions: number; deletions: number }) { return ( - - +{additions}{' '} - −{deletions} + + +{additions} −{deletions} ); } +const CI_STATE_META: Record< + CommitStatus['data']['state'], + { icon: string; color: string; word: string } +> = { + success: { icon: '✓', color: 'var(--ok)', word: 'passed' }, + failure: { icon: '✗', color: 'var(--bad)', word: 'failed' }, + error: { icon: '✗', color: 'var(--bad)', word: 'errored' }, + pending: { icon: '•', color: 'var(--slate)', word: 'running' }, +}; + +const isRedCheck = (s: CommitStatus) => s.data.state === 'failure' || s.data.state === 'error'; + +// failures first, then the still-running ones, then the greens; alphabetical +// within a tier so the list is stable run to run +function ciRank(s: CommitStatus): number { + return isRedCheck(s) ? 0 : s.data.state === 'pending' ? 1 : 2; +} + +/** completed − started, when both timestamps are known, as a terse "45s"/"6m". */ +function ciDuration(s: CommitStatus): string | null { + const { started_at, completed_at } = s.data; + if (started_at == null || completed_at == null) return null; + const secs = Math.max(0, completed_at - started_at); + return secs < 60 ? `${Math.round(secs)}s` : `${Math.round(secs / 60)}m`; +} + +/** + * CI as a rail chip: a small proportional bar (red-failing / slate-pending / + * quiet-green-passing) opening a per-check list — state, name, how long it + * took, and a link to each — the panel v1 had and v2 flattened to a single + * "CI: red" word. All-green stays a whisper (a single pale fill), not a + * checkmark — v1 users only ever "saw green on hover". A fixed-width + * placeholder (not null) stands in for a pull with no checks to show (ci + * 'none' / empty), so the rail's other slots don't shift column-to-column. + */ +export function CiStatus({ pull }: { pull: DerivedPull }) { + const checks = + pull.ci === 'none' + ? [] + : [...headStatuses(pull.data)].sort( + (a, b) => ciRank(a) - ciRank(b) || a.data.context.localeCompare(b.data.context) + ); + if (!checks.length) return ; + + const failing = checks.filter(isRedCheck).length; + const passing = checks.filter(c => c.data.state === 'success').length; + const pendingCount = checks.filter(c => c.data.state === 'pending').length; + const pending = pendingCount > 0; + const summary = failing + ? `CI: ${failing} of ${checks.length} failing` + : pending + ? `CI running · ${passing} of ${checks.length} passed` + : `CI passed · ${n(checks.length, 'check')}`; + // failing, then pending, then passing. Passed is invisible until you hover + // the bar (no news is good news — an all-green board shows NO bar at rest, + // and a mixed bar shows only its red/slate trouble); each segment's share + // of the bar is its own count, with a 3px floor so one failure among + // twenty checks stays visible + const segments = [ + { count: failing, background: 'var(--bad)' }, + { count: pendingCount, background: 'var(--slate)' }, + { count: passing, background: 'var(--ok)', quiet: true }, + ].filter(s => s.count > 0); + + return ( + ( + + )} + > + + CI checks + + · {passing} of {checks.length} passed + + + {checks.map(c => { + const meta = CI_STATE_META[c.data.state]; + const dur = ciDuration(c); + const inner = ( + <> + + {meta.icon} + + {c.data.context} + {meta.word} + {dur && ( + + {dur} + + )} + + ); + return c.data.target_url ? ( + + {inner} + + ) : ( + + {inner} + + ); + })} + + ); +} + /** - * Sign-off state as a pip meter: one square per required stamp, in a - * fixed-width slot so CR, QA, and age land at the same x down a board. Filled - * green = a live stamp; amber = a stamp a push invalidated, so a re-stamp is - * owed (the single most actionable state on the board — it can't hide inside - * a fraction); hollow = still needed; a muted dash = nothing required. A - * dotted underline marks a slot you personally stamped. No check, no slash — - * the fill is the whole vocabulary. + * Sign-off state as a circle-check meter: one mark per required stamp, in a + * fixed-width slot so CR, QA, and weight land at the same x down a board. + * One mark, three standings (see styles.css .pip): a solid disc = an + * approval that stands, the same mark drained to an outline = it stood once + * but a push lapsed it (the most actionable state on the board), an empty + * ring = still needed, a muted dash = nothing required. A dotted underline + * marks a slot you personally stamped. No enclosing chip at all: the marks + * are confident enough to stand bare beside their label — every box, wash, + * and hairline this slot has worn turned out to be scaffolding (the earlier + * colored squares and tinted backgrounds were a private code that sent eyes + * to the rail instead of the titles). */ export function Pips({ label, @@ -272,6 +465,12 @@ export function Pips({ ? `${label} done, including your stamp` : `${label} done` : `${label}: ${have} of ${req}`; + // No chip-level tint at all: a filled wash — however muted — out-competed + // the section headers and titles the eye should scan first (salience is + // about form, not volume). The re-stamp-owed signal lives on the stale pip + // itself instead: a thin amber halo confined to its own 8px square (see + // .pip-stale). The word-group header and the pip-mine underline already + // say whose move it is; the hover title spells out who and when. return ( {none ? ( - + ) : ( + // min-width sized to the COMMON case (two marks), not the 3-mark + // maximum — a fixed 3-wide slot left dead air before QA on almost + // every row; the rare 3-required pull just grows @@ -365,7 +570,7 @@ export function SigPips({ {...t} type="button" // no native title: the popover itself opens on this same hover - // py+negative-my: a real tap target (the pips are 8px squares) + // py+negative-my: a real tap target (the marks are ~10px glyphs) // without moving anything in the rail's layout className="pressable -my-2 cursor-pointer rounded border-0 bg-transparent px-0 py-2 text-left hover:bg-secondary/60" > diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index 54351064..4f7fff3c 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -182,6 +182,26 @@ export function ciFailing(pull: PullData, spec?: RepoSpec): string[] { const isFailing = (s: CommitStatus) => s.data.state === 'failure' || s.data.state === 'error'; +/** + * Every CI check on the current head, one row per context (a later report for + * the same context supersedes an earlier one), for the at-a-glance CI list the + * rail chip and its popover show. Unlike ciVerdict/ciFailing this ignores the + * repo's required/ignored config — the breakdown shows what actually ran, links + * and all, the way v1's CI panel did. Falls back to all statuses when none are + * tagged to the head sha (older payloads). + */ +export function headStatuses(pull: PullData): CommitStatus[] { + const onHead = pull.status.commit_statuses.filter(s => s.data.sha === pull.head.sha); + const statuses = onHead.length ? onHead : pull.status.commit_statuses; + const at = (s: CommitStatus) => s.data.completed_at ?? s.data.started_at ?? 0; + const latest = new Map(); + for (const s of statuses) { + const prev = latest.get(s.data.context); + if (!prev || at(s) >= at(prev)) latest.set(s.data.context, s); + } + return [...latest.values()]; +} + /** When did the author push the current head? Head-CI-start is the proxy. */ export function headPushedAt(pull: PullData): number | null { const starts = pull.status.commit_statuses diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index d0d73882..0dc2e666 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -182,29 +182,41 @@ label:has(> input:not(:disabled)) { .badge-red { background: var(--badge-red-bg); color: var(--badge-red-fg); } .badge-draft { background: none; color: var(--ink-3); border: 1px dashed var(--border); } - /* review-effort meter: five rising segments, N filled by weight class, - color ramping green→gray→amber→red. Replaces the XS–XL letter chip so - "how heavy is this to review" reads without decoding a size code. */ - .wt { display: inline-flex; align-items: flex-end; gap: 2px; height: 12px; } - .wt i { width: 3px; border-radius: 1px; background: var(--secondary); } - .wt i:nth-child(1) { height: 4px; } - .wt i:nth-child(2) { height: 6px; } - .wt i:nth-child(3) { height: 8px; } - .wt i:nth-child(4) { height: 10px; } - .wt i:nth-child(5) { height: 12px; } - - /* sign-off pips: one square per required stamp. Filled = a live stamp, - amber = a stamp a push invalidated (a re-stamp is owed), hollow = still - needed. This is the whole ledger vocabulary — no ✓, no ⊘. */ - .pip { width: 8px; height: 8px; border-radius: 2px; flex: none; - transition: background-color 240ms var(--ease-quint), box-shadow 240ms var(--ease-quint); } - .pip-on { background: var(--ok); } - .pip-stale { background: var(--warn); } - /* ink-3, not --border: the hollow "still needed" pip is a meaningful mark - and --border rings only hit ~1.5:1 against the surface — invisible to - low-vision users. ink-3 is tuned ≥4.5:1 in both themes. */ - .pip-off { background: transparent; box-shadow: inset 0 0 0 1.5px var(--ink-3); } - /* the slot you personally stamped: a dotted rule under the pips */ + /* sign-off marks: one circle-check per required stamp, drawn as SVG masks + so the geometry is machined — identical stroke weight and optical center, + never at the mercy of a font rasterizer (a text ✓ with text-stroke went + muddy/anemic at 10px). The mark is the identity; the FILL is the + standing: a solid disc = an approval that stands, the same mark drained + to an outline = it stood once but a later push lapsed it (re-confirm), + an empty ring = never approved. A muted dash (markup, not .pip) = not + required. Colored via background-color, so the tokens theme it. */ + /* 14px: simplicity must not whisper — the marks are the board's + decision-critical facts and, standing bare with no chip around them, + they carry the slot alone; strokes sized so the outline states read as + drawn, never wispy. */ + .pip { + width: 14px; + height: 14px; + flex: none; + transition: background-color 240ms var(--ease-quint); + -webkit-mask: var(--pip-shape) center / 100% 100% no-repeat; + mask: var(--pip-shape) center / 100% 100% no-repeat; + } + .pip-on { + --pip-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cdefs%3E%3Cmask id='k'%3E%3Crect width='16' height='16' fill='white'/%3E%3Cpath d='M4.6 8.4l2.3 2.3 4.5-5.2' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/mask%3E%3C/defs%3E%3Ccircle cx='8' cy='8' r='7.4' fill='white' mask='url(%23k)'/%3E%3C/svg%3E"); + background-color: var(--ok); + } + .pip-stale { + --pip-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='8' cy='8' r='6.6'/%3E%3Cpath d='M5.1 8.3l1.9 1.9 3.9-4.4'/%3E%3C/g%3E%3C/svg%3E"); + background-color: var(--warn); + } + /* ink-3, not --border: the "still needed" ring is meaningful and must stay + ≥4.5:1 in both themes for low-vision users */ + .pip-off { + --pip-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.6' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E"); + background-color: var(--ink-3); + } + /* the slot you personally stamped: a dotted rule under the marks */ .pip-mine { border-bottom: 1px dotted var(--ink-3); padding-bottom: 2px; } /* row flags: quiet colored labels, not badges — amber for act-on-it, @@ -222,6 +234,68 @@ label:has(> input:not(:disabled)) { } +/* Each card row is its own inline-size query container, so the metric rail can + decide to break onto its own line by measured COLUMN WIDTH rather than by how + long this particular row's meta chips happened to be. That's the whole fix + for "cards don't align": today the rail is ml-auto inside the meta flex-wrap, + so it right-aligns on line 1 when it fits (wide Review lanes) but drops to an + incidental, per-row position when it doesn't (narrow Classic columns). The + @container rule below makes that break deterministic and identical for every + card in the same column, so the weight/CR/QA/CI/age columns line up down the + list. inline-size (not size) leaves row height auto; pd-row is already + `relative`, so the containing-block it now establishes for the .pd-link::after + stretch layer and the stack connector is unchanged. */ +.pd-row { + container-type: inline-size; +} +/* comfortable density renders the rail as a DIRECT child of the row (compact + nests it in the flowing text span, so this selector skips it): center it + vertically so the two-line card's height reads as deliberate space around + the marks — controls-centered-right, like a mail client's list row. */ +.pd-row > .pd-rail { + align-self: center; +} +/* below a width only ever hit by Classic's ~300-450px columns (Review's + full-width lanes stay well above it, so they keep today's inline right-anchored + rail), force the rail onto its own line, still right-aligned via its own + justify-end. flex-basis:100% can't share a line with anything, so it wraps + whole. */ +@container (max-width: 520px) { + .pd-rail { + flex-basis: 100%; + margin-left: 0; + margin-top: 1px; + /* chips flow left under the text (v1's card anatomy) instead of + right-anchoring into ragged centered-looking lines */ + justify-content: flex-start; + position: relative; + } + /* the hover action cluster floats over the rail's right end instead of + reserving ~70px of a 200px-wide rail line (visibility:hidden still takes + layout space; in a narrow column that reservation shoved the chips) */ + .pd-rail .row-actions { + position: absolute; + right: 0; + top: 50%; + translate: 0 -50%; + z-index: 10; + background: var(--muted); + border-radius: 6px; + padding-inline: 4px; + } +} +/* below 360px, hide the one optional chip — the code-region tag (Row.tsx + applies .pd-chip-optional to it alone; the +/- diff size isn't inline on + cards at all, it lives in the weight popover) — so the title and rail keep + the width in a genuinely cramped column (Classic's tightest 3-4-up columns, + or a phone). Its content stays reachable via the row's title attribute and + the regions lane, so nothing is truly lost. */ +@container (max-width: 360px) { + .pd-chip-optional { + display: none; + } +} + /* whole-card click target: the title link stretches an invisible layer over its positioned row, so clicking anywhere opens the PR. Genuinely interactive children (avatar, ledger, actions) sit above it via .pd-raise. */ @@ -352,17 +426,9 @@ label:has(> input:not(:disabled)) { gap: 0.375rem; } :root[data-density="compact"] .pip { - width: 7px; - height: 7px; -} -:root[data-density="compact"] .wt { - height: 10px; + width: 12px; + height: 12px; } -:root[data-density="compact"] .wt i:nth-child(1) { height: 3px; } -:root[data-density="compact"] .wt i:nth-child(2) { height: 5px; } -:root[data-density="compact"] .wt i:nth-child(3) { height: 6px; } -:root[data-density="compact"] .wt i:nth-child(4) { height: 8px; } -:root[data-density="compact"] .wt i:nth-child(5) { height: 10px; } /* settings slide-out from the right, with a fading scrim */ @keyframes panel-in { From 80f4bbf56b164019b973841b4bf53d2bd4062640 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 21:13:57 -0700 Subject: [PATCH 215/381] Temper the --ok/--warn/--bad/--violet color tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-07 color audit: red had shipped more saturated than the brand accent, and green read as loud as red — backwards for a restrained board where red=broken should be rare, amber=you-owe-something, and green is a quiet confirmation, not a shout. The dark tokens were worse: 70-84% lightness at full chroma read as neon on a near-black canvas, worst offender --warn as highlighter yellow. Retune all four to a calmer hierarchy in both themes; --violet moves to an OKLCH triple so its perceived lightness matches its neighbors instead of an sRGB hex pick. Worst-case contrast after the change is --bad in dark mode at 4.58:1, still AA. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/styles.css | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 0dc2e666..b24d06a6 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -35,11 +35,15 @@ --badge-slate-fg: #334155; --slate: #475569; /* text-grade in light mode: --ok/--warn also fill the 8px sign-off pips, - the most decision-critical marks on the board, and must stay legible */ - --ok: #15803d; - --warn: #b45309; - --bad: #b91c1c; - --violet: #9333ea; + the most decision-critical marks on the board, and must stay legible. + Chroma deliberately tempered (2026-07 color audit): red had shipped MORE + saturated than the brand accent, and green as loud as red — backwards for + a restrained tool where red = broken (rare), amber = you owe something, + green = quiet confirmation. All values still clear AA. */ + --ok: #2b663e; + --warn: #985521; + --bad: #a83630; + --violet: oklch(52% 0.15 300); --ease-quint: cubic-bezier(0.22, 1, 0.36, 1); --ease-quart: cubic-bezier(0.25, 1, 0.5, 1); } @@ -76,10 +80,14 @@ --badge-slate-bg: #334155; --badge-slate-fg: #dde7f2; --slate: #94a3b8; - --ok: #4ade80; - --warn: #fbbf24; - --bad: #f87171; - --violet: #c084fc; + /* dark tokens ran at 70-84% lightness with full chroma — neon on a near- + black canvas (the audit's worst offender was --warn as highlighter + yellow). Same tempered hierarchy as light; worst contrast is --bad at + 4.58:1, still AA. */ + --ok: #66b27c; + --warn: #d8953d; + --bad: #cd605a; + --violet: oklch(72% 0.13 300); } @theme inline { From debd24cc2aac0cf48d1fa063132b97cf76c3737d Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 21:20:48 -0700 Subject: [PATCH 216/381] Replace per-card status badges with word-grouped sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every open card carried a colored StatusBadge restating its status, plus a separate badge-do pill when it was your move, plus a "review requested" chip, plus a context sentence saying much the same thing in prose — four ways to say one fact, competing with the title for a glance-scan down a column. Introduce rowWord (model/actions.ts, prior commit): a one-word, ranked key derived from the same rowNote the badges were rendering, and group cards under it instead. Add components/WordGroups.tsx: stacks pulls, buckets them by rowWord, and renders a quiet section header ("Re-stamp · 3", "awaiting CR · 2") followed by that bucket's rows — the badge's replacement, brand-tinted for a do-group, muted for a wait-group. Row.tsx's meta line drops the status badge, the do-pill, the "review requested" chip, and the diff size entirely; the repo#number ref becomes the state popover's door (renamed from StatusBadgeTrigger to the more general StatePopover, which now wraps arbitrary trigger content instead of only the badge), and AgeStamp moves onto the meta line since age is a fact about the pull, not a rail action. Wire WordGroupRows into every lens: Review's old flat "Yours to do" / "Requested of you" / "You're reviewing" lanes collapse into "Your move" (re-bucketed by rowWord, most urgent first) and "Yours, waiting" (your own stake, including the stamped/QA'd-by-you folds this replaces), with a "Pick up next" label over the suggestion lanes below to mark them as offered rather than owed work. MyWork and Classic's columns switch from a flat stack render to WordGroupRows the same way. Classic's Column gains a min-h so an emptied column (WordGroupRows can render nothing) doesn't collapse its rounded-b corners to a square. Legend's "Badges" section becomes "Groups", describing the two header colors instead of three retired badge chips. Rider: this commit also carries Review.tsx's Deal-me-one card rebuild (DealtCard now renders in the board row's own anatomy — StatePopover door, inline AgeStamp, CI/pips/weight rail) because its diff shares an import-line hunk with the lane restructure above and the two couldn't be split cleanly; see the next commit for why DealtCard changed. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Column.tsx | 6 +- frontend-v2/src/components/Legend.tsx | 33 ++- frontend-v2/src/components/Row.tsx | 95 +++----- frontend-v2/src/components/StatePopover.tsx | 74 ++---- frontend-v2/src/components/WordGroups.tsx | 125 ++++++++++ frontend-v2/src/components/bits.tsx | 7 +- frontend-v2/src/views/Classic.tsx | 22 +- frontend-v2/src/views/MyWork.tsx | 19 +- frontend-v2/src/views/Review.tsx | 241 ++++++++++++-------- 9 files changed, 373 insertions(+), 249 deletions(-) create mode 100644 frontend-v2/src/components/WordGroups.tsx diff --git a/frontend-v2/src/components/Column.tsx b/frontend-v2/src/components/Column.tsx index a4b628c4..a354f3f0 100644 --- a/frontend-v2/src/components/Column.tsx +++ b/frontend-v2/src/components/Column.tsx @@ -51,7 +51,11 @@ export function BoardColumn({ {open && ( -
+ // min-h keeps the rounded-b corners from collapsing to square when + // the body is empty (Classic keeps empty columns visible): a ~1px + // box has no room to draw a 16px radius. Any populated column far + // exceeds this, so it only shows on the empty case. +
{children}
)} diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 547ea33d..14d03b6d 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; import { useSettings } from '../settings'; -import { FreshTag, WeightMeter } from './bits'; +import { WeightMeter } from './bits'; import { Popover } from './Popover'; /** a bare CI bar for the legend, without the popover/button chrome the real @@ -311,31 +311,26 @@ export function Legend() { - + Dev blocked} - def="a reviewer requested changes: the author’s move" - /> - Deploy block} - def="done, deliberately kept from shipping: ask who blocked it" + term={ + + Re-stamp · 3 + + } + def="brand group header: cards under it are your move — the verb to do" /> Can’t merge} - def="signed off but conflicted or on an unmerged parent: author rebases" + term={ + + awaiting CR · 2 + + } + def="gray group header: why those cards wait. Hover a card’s repo #number for the full state — status, names, dates, CI, feedback" /> - - - - - } - def="changed since your last visit: “new” is a brand-new PR, “updated” an existing one that changed. Opening one clears it until it changes again" - /> diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 20883850..b1673b36 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -2,7 +2,6 @@ import { memo, useState, type ReactNode } from 'react'; import type { DerivedPull } from '../model/status'; import { isIterating, lastPushEpoch } from '../model/status'; import { rowNote } from '../model/actions'; -import { requestedReviewers, reviewRequestedFrom } from '../model/reviewers'; import { matchedRegions } from '../model/regions'; import type { ParentRef } from '../model/stack'; import { ago, epoch, pullKey, rowDomId, shortRepo } from '../format'; @@ -23,9 +22,9 @@ import { snoozePull, usePulldasher, } from '../store'; -import { AgeStamp, DiffSize, FreshTag, RepoRef, SigPips, WeightMeter } from './bits'; +import { AgeStamp, CiStatus, DiffSize, RepoRef, SigPips, WEIGHT_WORD, WeightMeter } from './bits'; import { CardShell } from './Card'; -import { ContextPopover, StatusBadgeTrigger } from './StatePopover'; +import { StatePopover } from './StatePopover'; import { Popover } from './Popover'; export interface RowOptions { @@ -731,11 +730,11 @@ function RowImpl({ const claim = claimFor(d, opts.claims ?? {}); const turn = opts.turns?.get(key) ?? null; const poolSize = opts.pools?.get(d.repo)?.length ?? 0; - // the two-slot action/context note, the same in every lens (model/actions.ts) - // — never both-null for an open pull, so the context text always renders + // the viewer-relative note (model/actions.ts): never both-null for an open + // pull, so the one badge below always has something to say const note = rowNote(pull, opts.me, { claim, turn }); - // "iterating" and a "fix pushed …" note say the same thing — don't say it twice - const showIterating = isIterating(pull) && !(note.context ?? '').includes('pushed'); + // the wait badge may itself carry a "last commit …" — don't say it twice + const showIterating = isIterating(pull) && !(note.context ?? '').includes('last commit'); // the smallest clean marker for a starred author: a tiny ★ over their // avatar, so the row itself says "you follow this person" without a // trip to the kebab menu @@ -743,25 +742,9 @@ function RowImpl({ const starredAuthor = settings.starredPeople.includes(d.user.login); // which of your code regions this pull matched (why it floated to the top) const regions = matchedRegions(pull, settings.codeRegions); - // GitHub explicitly asked you to review this — the strongest "review this" - // signal there is. Shown only while it's still your move: not once you've - // stamped, and not when it's already your claim (the lit hand says that). - const requestedOfMe = - (pull.status === 'needs_cr' || pull.status === 'needs_recr') && - reviewRequestedFrom(pull, opts.me) && - !pull.crBy.includes(opts.me) && - claim?.login !== opts.me; - // who else GitHub also asked, alongside opts.me — only matters for the - // chip's title below, so skip the filter when the chip won't render - const otherRequested = requestedOfMe ? requestedReviewers(pull).filter(l => l !== opts.me) : []; // only worth asking the whole-board lookup when this row is stacked but // rendering flat (its parent isn't visible right above it already) const orphanParent = pull.dependent && depth === 0 ? (opts.parentOf?.(pull) ?? null) : null; - // the "review requested" chip already states this — strip it from the - // context line so one card doesn't carry the same fact as chip AND text - const contextText = requestedOfMe - ? note.context?.replace(/^requested from you( · )?/, '') || null - : note.context; return ( - {fresh && } - {/* the status badge always shows the state, and now doubles as - the state popover's trigger; when there's also a viewer - move, the do-pill rides right after it, so "what's - happening" and "what to do" both stay visible at once */} - + + + - {note.action && {note.action}} - {requestedOfMe && ( - 0 ? ` (also ${otherRequested.join(', ')})` : '' - }`} - className="chip-in inline-flex flex-none items-center gap-1 rounded bg-brand px-1.5 py-0.5 text-[11px] leading-none font-medium text-surface" - > - - review requested - - )} - {regions.length > 0 && ( + // a neutral chip, only the ◆ in brand: region-match is soft + // personalization, not urgency — a filled brand chip diluted + // "blue = your move" (color audit) 1 ? 'regions' : 'region'}: ${regions.join(', ')}`} - className="chip-in inline-flex flex-none items-center gap-1 rounded bg-brand-50 px-1.5 py-0.5 text-[11px] leading-none font-medium text-brand-700" + className="pd-chip-optional chip-in inline-flex flex-none items-center gap-1 rounded border border-line px-1.5 py-0.5 text-[11px] leading-none font-medium text-ink-2" > - + + ◆ + {regions.slice(0, 2).join(', ')} {regions.length > 2 && ` +${regions.length - 2}`} )} - {pull.sizeKnown && ( - - )} - {contextText && ( - - )} ( - - )} - > - - - ); -} - -/** - * The context line's own door into the same popover: when a pull's context - * text has feedback behind it (an active dev/deploy block, an unstamped - * review), the text becomes a second trigger for the same full-state content - * — instead of a screenful of clicking through to GitHub. Falls back to - * plain text when there's nothing to show, same as SigPips falls back to - * plain pips with no signatures. - */ -export function ContextPopover({ - pull, - me, - text, - claim, - turn, - poolSize, -}: { - pull: DerivedPull; - me: string; - text: string; - claim?: { login: string; at: number } | null; - turn?: string | null; - poolSize?: number; -}) { - const hasFeedback = feedbackSources(pull).length > 0; - if (!hasFeedback) return {text}; - - return ( - ( - )} > diff --git a/frontend-v2/src/components/WordGroups.tsx b/frontend-v2/src/components/WordGroups.tsx new file mode 100644 index 00000000..5bb341eb --- /dev/null +++ b/frontend-v2/src/components/WordGroups.tsx @@ -0,0 +1,125 @@ +import type { ReactNode } from 'react'; +import { DO_WORD_RANK, rowWord, WAIT_WORD_RANK, type RowWord } from '../model/actions'; +import { groupIntoTree, type StackedPull } from '../model/stack'; +import type { DerivedPull } from '../model/status'; +import { pullKey } from '../format'; +import { claimFor } from '../store'; +import { Truncated } from './Lane'; +import { Row, type RowOptions } from './Row'; + +/** + * The one eyebrow-label type treatment: every 11px uppercase in-list label on + * the board (word sub-headers here, Review's "Pick up next" band label) shares + * this string so the tier can't drift apart one hand-rolled copy at a time. + */ +export const eyebrowText = 'text-[11px] font-semibold tracking-wide uppercase'; + +/** + * The quiet in-list label every word-grouped list splits on — the badge's + * replacement: instead of a per-card pill, the section itself says what's + * owed (brand) or why it waits (muted). Mirrors Classic's old SubHeader + * styling so the two lenses still read as one design. + */ +export function WordSubHeader({ + word, + kind, + count, +}: { + word: string; + kind: RowWord['kind']; + count: number; +}) { + return ( +
+ {word}{' '} + · {count} +
+ ); +} + +export interface WordGroup { + word: string; + kind: RowWord['kind']; + nodes: StackedPull[]; +} + +/** + * Buckets a stack-aware tree (model/stack.ts's groupIntoTree) by each root + * pull's rowWord — the same word that would have been the card's badge. + * A stacked child always follows its root into the root's bucket, so a + * stack never splits across two headers. Buckets are keyed by (kind, word) + * so every "Re-stamp" card lands in one group regardless of where it fell in + * the input order; the RETURN order is what's ranked — do-groups first (most + * urgent DO_WORD_RANK first), then wait-groups (WAIT_WORD_RANK), unknown + * words last, ties kept in first-seen order (Array#sort is stable). + */ +export function groupNodesByWord( + tree: StackedPull[], + me: string, + claims: Readonly> +): WordGroup[] { + const buckets = new Map(); + let currentKey: string | null = null; + for (const node of tree) { + if (node.depth === 0) { + const rw = rowWord(node.pull, me, { claim: claimFor(node.pull.data, claims) }); + currentKey = `${rw.kind}:${rw.word}`; + if (!buckets.has(currentKey)) { + buckets.set(currentKey, { word: rw.word, kind: rw.kind, nodes: [] }); + } + } + // depth > 0 with no depth-0 seen yet can't happen — groupIntoTree always + // places a root before its children — but the guard keeps this total. + if (currentKey) buckets.get(currentKey)!.nodes.push(node); + } + const rankOf = (g: WordGroup): number => { + const rank = g.kind === 'do' ? DO_WORD_RANK : WAIT_WORD_RANK; + const idx = rank.indexOf(g.word); + return idx === -1 ? Number.POSITIVE_INFINITY : idx; + }; + const kindOrder = (k: RowWord['kind']): number => (k === 'do' ? 0 : 1); + return [...buckets.values()].sort( + (a, b) => kindOrder(a.kind) - kindOrder(b.kind) || rankOf(a) - rankOf(b) + ); +} + +/** + * The full word-grouped renderer: stack the pulls, bucket them by rowWord, + * and render each bucket as a header followed by its rows — exactly the rows + * FoldRows would produce, just split by section instead of one flat run. + * Headers don't count against `cap` (the truncation only rations rows), so + * the cap is lifted by the header count before handing it to Truncated. + */ +export function WordGroupRows({ + pulls, + opts, + id, + cap, +}: { + pulls: DerivedPull[]; + opts: RowOptions; + id?: string; + cap: number; +}) { + const tree = groupIntoTree(pulls); + const groups = groupNodesByWord(tree, opts.me, opts.claims ?? {}); + if (!groups.length) return null; + const children: ReactNode[] = groups.flatMap(g => [ + , + ...g.nodes.map(({ pull: p, depth }) => ( + + )), + ]); + return ( + + {children} + + ); +} diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 4f5110ad..96d8b153 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -628,6 +628,7 @@ export function AgeStamp({ quiet, warnDays = STARVE_DAYS, rotDays = ROT_DAYS, + inline, }: { ageDays: number; /** epoch secs the pull opened */ @@ -640,6 +641,10 @@ export function AgeStamp({ warnDays?: number; /** red at/after this many days */ rotDays?: number; + /** true when it's embedded in a flowing meta line rather than the rail's + * fixed-width column — drops the w-7/text-right slot in favor of plain + * inline text. */ + inline?: boolean; }) { const hot = quiet ? null @@ -669,7 +674,7 @@ export function AgeStamp({ > {text} diff --git a/frontend-v2/src/views/Classic.tsx b/frontend-v2/src/views/Classic.tsx index e6d2ea85..1899db19 100644 --- a/frontend-v2/src/views/Classic.tsx +++ b/frontend-v2/src/views/Classic.tsx @@ -1,12 +1,13 @@ import { crDone, qaDone, type DerivedPull } from '../model/status'; -import { groupIntoTree } from '../model/stack'; import type { PullData } from '../types'; import { ago, closedEpoch, pullKey } from '../format'; import { ClosedBadge, EmptyState, RepoRef } from '../components/bits'; import { CardShell } from '../components/Card'; import { BoardColumn } from '../components/Column'; import { laneShown, Truncated } from '../components/Lane'; -import { Row, type RowOptions } from '../components/Row'; +import { WordGroupRows } from '../components/WordGroups'; +import { ClosedRow } from '../components/ClosedRow'; +import type { RowOptions } from '../components/Row'; /** * The v1 board, faithfully: the same six overlapping columns, the same @@ -63,15 +64,18 @@ function Column({ opts: RowOptions; defaultOpen?: boolean; }) { - const tree = groupIntoTree(pulls); + // v1's sort already floats the viewer's own concerns to the top (your PRs, + // an owed re-stamp, a QA you're running), so within each rowWord group the + // order stays v1-faithful — WordGroupRows only re-buckets by word, it + // doesn't re-sort. return ( - {/* a 60-row CR column is a 3600px scroll: cap it, keep the count honest */} - - {tree.map(({ pull: p, depth }) => ( - - ))} - + ); } diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index b34cca86..9bf7031e 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -1,11 +1,11 @@ import type { DerivedPull } from '../model/status'; import { authorMove } from '../model/actions'; -import { groupIntoTree } from '../model/stack'; import type { PullData } from '../types'; import { pullKey } from '../format'; import { EmptyState } from '../components/bits'; import { Fold, Lane, laneShown, RestGroup, Truncated } from '../components/Lane'; -import { Row, type RowOptions } from '../components/Row'; +import type { RowOptions } from '../components/Row'; +import { WordGroupRows } from '../components/WordGroups'; import { ClosedRow } from '../components/ClosedRow'; /** @@ -30,8 +30,6 @@ export function MyWork({ const move = mine.filter(p => authorMove(p) !== null).sort(byUrgency); const waiting = mine.filter(p => authorMove(p) === null).sort(byUrgency); const shipped = closed.filter(p => p.user.login === me); - const moveTree = groupIntoTree(move); - const waitingTree = groupIntoTree(waiting); if (!mine.length && !shipped.length) { return ( @@ -45,9 +43,7 @@ export function MyWork({ return ( <> - {moveTree.map(({ pull: p, depth }) => ( - - ))} + {!move.length && (
Nothing needs you right now. @@ -55,9 +51,12 @@ export function MyWork({ )} - {waitingTree.map(({ pull: p, depth }) => ( - - ))} + {!waiting.length && (
Nothing is waiting on anyone else. diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index e5d828b9..faf2b0c6 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -3,18 +3,24 @@ import { type DerivedPull, qaDone, type Status, weightRank } from '../model/stat import { epoch, pullKey } from '../format'; import { crSort, starFirst } from '../model/sort'; import { matchesRegion } from '../model/regions'; -import { groupIntoTree } from '../model/stack'; -import { authorMove, reviewerMove } from '../model/actions'; +import { + authorMove, + DO_WORD_RANK, + reviewerMove, + rowNote, + rowWord, + WAIT_WORD_RANK, +} from '../model/actions'; import { reviewRequestedFrom } from '../model/reviewers'; import { startHereReason } from '../model/cheers'; import { dealOne } from '../model/deal'; import { useSettings } from '../settings'; -import { claimReview, isFresh, usePulldasher } from '../store'; +import { claimFor, claimReview, isFresh, usePulldasher } from '../store'; import type { PullData } from '../types'; import { AgeStamp, Avatar, - DiffSize, + CiStatus, EmptyState, PullTitleLink, QuietButton, @@ -24,78 +30,86 @@ import { STATUS_LABEL, WeightMeter, } from '../components/bits'; +import { StatePopover } from '../components/StatePopover'; import { Fold, FoldRows, Lane, laneShown, RestGroup, Truncated } from '../components/Lane'; import { onOpen, Popover } from '../components/Popover'; import { RegionHint } from '../components/RegionHint'; -import { markDealtFlash, Row, type RowOptions } from '../components/Row'; +import { markDealtFlash, type RowOptions } from '../components/Row'; +import { eyebrowText, WordGroupRows } from '../components/WordGroups'; import { ClosedRow } from '../components/ClosedRow'; /** - * The dealt pull, shown as a real card inside the Deal-me-one popover: avatar - * and author, the title as a GitHub link, the same weight / size / age / state - * metrics a row carries, the CR-QA pips, and one line on why this one came up. - * Claiming it is a commitment (it also adds you as a GitHub reviewer), so it's - * an explicit button, not a side effect of dealing. + * The dealt pull, shown as a real card inside the Deal-me-one popover, in the + * board row's exact anatomy: author line with the repo# state-popover door and + * age, the title as a GitHub link, then the rail's marks in the rail's order + * (CI → CR/QA pips → weight strip). The "why this one" line is a quiet + * footnote, not a chip — reasons are context, not state. Claiming it is a + * commitment (it also adds you as a GitHub reviewer), so it's an explicit + * button, not a side effect of dealing. */ function DealtCard({ pull, - me, + opts, pulls, onClaim, onPass, }: { pull: DerivedPull; - me: string; + opts: RowOptions; pulls: DerivedPull[]; onClaim: () => void; onPass: () => void; }) { const d = pull.data; + const me = opts.me; return (
{d.user.login} - -
-
- -
-
- - {pull.sizeKnown && ( - - )} - {STATUS_LABEL[pull.status]} + + +
-
- - +
+
-
- 🎯 - {startHereReason(pull, pulls, me)} +
+ + + + + + + + +
+
+ {startHereReason(pull, pulls, me)}
@@ -186,7 +200,7 @@ function DealButton({ )} > {dealt ? ( - + ) : (
Nothing left to deal in this queue.
)} @@ -255,9 +269,6 @@ export function Review({ (a, b) => MOVE_RANK.indexOf(a.verb) - MOVE_RANK.indexOf(b.verb) || b.p.ageDays - a.p.ageDays ); - // Row recomputes its own verb via rowNote, so reordering by stack (and - // dropping the { p, verb } wrapper) loses nothing the row needs. - const todoTree = groupIntoTree(todo.map(({ p }) => p)); // 2. Review queue: best next review first (leverage + age + weight). // Includes pulls waiting on someone else's re-stamp — a fresh CR from @@ -384,6 +395,52 @@ export function Review({ // icon buried in a lower lane; it's your commitment, surfaced up top. const claimed = crSort(pulls.filter(p => opts.claims?.[pullKey(p.data)]?.login === me)); + // A push answered the feedback, so the ball is back with whoever asked for + // changes — their move now is to re-review, not to wait some more. + const reReview = others.filter(p => rowNote(p, me).action === 'Re-review'); + + // "Your move": every lane above that's owed by you, folded into one flat + // list and re-bucketed by rowWord (Requested of you / You're reviewing no + // longer stand alone — their members show up here, grouped by verb instead + // of by where they came from). + const yourMoveKeys = new Set(); + const yourMove: DerivedPull[] = []; + for (const p of [...todo.map(({ p }) => p), ...requestedOfYou, ...claimed, ...reReview]) { + const k = pullKey(p.data); + if (yourMoveKeys.has(k)) continue; + yourMoveKeys.add(k); + yourMove.push(p); + } + const doRankOf = (p: DerivedPull) => { + const word = rowWord(p, me, { claim: claimFor(p.data, opts.claims ?? {}) }).word; + const idx = DO_WORD_RANK.indexOf(word); + return idx === -1 ? Number.POSITIVE_INFINITY : idx; + }; + yourMove.sort((a, b) => doRankOf(a) - doRankOf(b) || b.ageDays - a.ageDays); + + // "Yours, waiting": your stake that's sitting on someone else right now — + // your own PRs waiting on a review/QA, plus a stamp you've already given + // that isn't fully signed off yet (stamped/qaStamped, formerly their own + // rest-group folds — redundant once this lane exists). + const yoursWaitingKeys = new Set(); + const yoursWaiting: DerivedPull[] = []; + for (const p of [ + ...pulls.filter(p => p.data.user.login === me && rowWord(p, me).kind === 'wait'), + ...stamped, + ...qaStamped, + ]) { + const k = pullKey(p.data); + if (yoursWaitingKeys.has(k)) continue; + yoursWaitingKeys.add(k); + yoursWaiting.push(p); + } + const waitRankOf = (p: DerivedPull) => { + const word = rowWord(p, me, { claim: claimFor(p.data, opts.claims ?? {}) }).word; + const idx = WAIT_WORD_RANK.indexOf(word); + return idx === -1 ? Number.POSITIVE_INFINITY : idx; + }; + yoursWaiting.sort((a, b) => waitRankOf(a) - waitRankOf(b) || b.ageDays - a.ageDays); + // In your code regions: reviewable pulls (CR or QA pool) matching a region // you set in Settings, deduped across the two pools and pulled out of the // queue/QA lanes below into their own section — the most explicit "this is @@ -402,16 +459,21 @@ export function Review({ // group's folds become the main event — they should greet you open, not // as a wall of closed triangles const boardIsQuiet = - !todo.length && !changed.length && !queue.length && !aged.length && !needsQa.length; + !yourMove.length && + !yoursWaiting.length && + !changed.length && + !queue.length && + !aged.length && + !needsQa.length; // the rest group itself earns a title only when it has something inside — - // an empty "rest of the board" with 11 closed folds under it is still noise + // an empty "rest of the board" with 11 closed folds under it is still noise. + // stamped/qaStamped moved into "Yours, waiting" above, so they no longer + // count here. const restTotal = queueOther.length + needsQaOther.length + ready.length + - stamped.length + - qaStamped.length + devBlocked.length + deployHeld.length + unmergeable.length + @@ -435,32 +497,37 @@ export function Review({ return ( <> {codeRegions.length === 0 && } - {todo.length > 0 && ( - - - {todoTree.map(({ pull: p, depth }) => ( - - ))} - - - )} - {requestedOfYou.length > 0 && ( + {yourMove.length > 0 && ( + > + + )} - {claimed.length > 0 && ( + {yoursWaiting.length > 0 && ( + > + + )} )} + {/* below here is offered work, not owed work — the board's suggestion + for what to pick up next, as distinct from "Your move" above. The + label only earns its place when something is actually on offer. */} + {(aged.length > 0 || queue.length > 0 || needsQa.length > 0) && ( +
Pick up next
+ )} - - - - - - Date: Tue, 21 Jul 2026 21:22:14 -0700 Subject: [PATCH 217/381] Unify closed-row and dead-badge cleanup across lenses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small consistency pass now that the word-group redesign has landed: - Classic's "Recently Closed" column had its own local ClosedCard component, duplicating what ClosedRow (Review/MyWork's shared closed row) already does; switch it to the shared component so a closed pull reads the same everywhere, and its title no longer truncates (Row's title never truncates either — nothing on this board should hide a title mid-word). - People's "re-stamps they owe others" fold dot moves from --brand to --warn: brand now means "your move" everywhere else on the board, and this fold isn't yours. - Team's empty state switches to the shared EmptyState component instead of a one-off paragraph. - Lane's fold counts drop their bold weight — a small heading, not a number that should outweigh the label next to it. - StatePopover's FactsSection gains the PR's branch name (the row's copy action only tucks it in a tooltip) and drops the redundant "· updated X ago", which duplicated the state line's own "last commit X ago" for the same event. - StatusBadge, its STATUS_CLASS lookup, and the .badge-do/.badge-qa styles and --badge-purple tokens they used are now genuinely dead — the word-group redesign removed the last card that rendered a status badge — so delete them instead of leaving unused exports behind. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/ClosedRow.tsx | 3 +- frontend-v2/src/components/Lane.tsx | 2 +- frontend-v2/src/components/StatePopover.tsx | 11 ++++-- frontend-v2/src/components/bits.tsx | 30 ++-------------- frontend-v2/src/styles.css | 13 ------- frontend-v2/src/views/Classic.tsx | 39 +++++---------------- frontend-v2/src/views/People.tsx | 2 +- frontend-v2/src/views/Team.tsx | 4 +-- 8 files changed, 26 insertions(+), 78 deletions(-) diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index 4638b863..157491c0 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -10,6 +10,7 @@ import { ClosedBadgeTrigger } from './StatePopover'; * under it — so nothing competes with the title for width. The old layout kept * the repo ref + age in a flex-none whitespace-nowrap cluster on the right, * which on a phone starved the title to ~0px and stacked it one char per line. + * Nothing here truncates: the title wraps instead. * Memoized like Row: closed rows live in folds that re-render with every * publish, but a closed pull's data never changes again. */ @@ -40,7 +41,7 @@ export const ClosedRow = memo(function ClosedRow({ - + diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index 6e80a375..4a9072f8 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -263,7 +263,7 @@ export function Fold({ ▸ - {count} {label} + {count} {label} {hint && {hint}}
{children}
diff --git a/frontend-v2/src/components/StatePopover.tsx b/frontend-v2/src/components/StatePopover.tsx index ffbaeb52..d2336f6a 100644 --- a/frontend-v2/src/components/StatePopover.tsx +++ b/frontend-v2/src/components/StatePopover.tsx @@ -133,10 +133,17 @@ function FactsSection({ : 'none required'; return (
+ {/* no "· updated X ago" here: it duplicated the "last commit X ago" + the state line above already shows (same event, same timestamp) */}

opened {ago(epoch(d.created_at))} ago by{' '} - {d.user.login} · updated{' '} - {ago(epoch(d.updated_at))} ago + {d.user.login} +

+ {/* the one readable home for the branch name — the row's copy action + only tucks it in a tooltip, and "which branch is this?" shouldn't + need a trip to GitHub */} +

+ branch {d.head.ref}

CR {pull.crHave} of {crReq} diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 96d8b153..333db35f 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -41,23 +41,6 @@ export const STATUS_LABEL: Record = { draft: 'Draft', }; -// ci_pending / dev_block / unmergeable used to share amber, which made -// "only CI left" (benign, nearly done) look as urgent as "dev blocked" (you -// owe feedback). Three distinct tones now: calm slate for the CI wait, amber -// for the action-owed dev block, gray for the git-conflict rebase. -const STATUS_CLASS: Record = { - ready: 'badge-ready', - ci_pending: 'badge-slate', - needs_recr: 'badge-recr', - needs_qa: 'badge-qa', - needs_cr: 'badge-cr', - dev_block: 'badge-blocked', - deploy_block: 'badge-hold', - unmergeable: 'badge-hold', - ci_red: 'badge-red', - draft: 'badge-draft', -}; - export const STATUS_DOT: Record = { ready: 'var(--ok)', ci_pending: 'var(--slate)', @@ -99,18 +82,9 @@ export function QuietButton({ ); } -export function StatusBadge({ status, inline }: { status: Status; inline?: boolean }) { - return ( - - {STATUS_LABEL[status]} - - ); -} - /** - * Merged/closed state as a pill, in the same badge vocabulary StatusBadge uses - * for every open state — so a closed pull reads with the same grammar as an - * open one instead of the ad-hoc colored text the board used to show. + * Merged/closed state as a pill — the one badge left on the board; open pulls + * are badge-less and carry state via section position + the state popover. */ export function ClosedBadge({ merged, inline }: { merged: boolean; inline?: boolean }) { return ( diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index b24d06a6..912cb350 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -25,8 +25,6 @@ --badge-red-fg: #7f1d1d; --badge-amber-bg: #fde68a; --badge-amber-fg: #78350f; - --badge-purple-bg: #e9d5ff; - --badge-purple-fg: #581c87; --badge-brand-bg: var(--brand-100); --badge-brand-fg: var(--brand-900); --badge-gray-bg: #e5e7eb; @@ -71,8 +69,6 @@ --badge-red-fg: #fecaca; --badge-amber-bg: #78350f; --badge-amber-fg: #fde68a; - --badge-purple-bg: #581c87; - --badge-purple-fg: #e9d5ff; --badge-brand-bg: #123a5f; --badge-brand-fg: #cce7ff; --badge-gray-bg: #364153; @@ -173,15 +169,6 @@ label:has(> input:not(:disabled)) { } .badge-ready { background: var(--badge-green-bg); color: var(--badge-green-fg); } .badge-recr { background: var(--badge-brand-bg); color: var(--badge-brand-fg); } - /* the imperative for YOUR move: a status badge's shape, a lighter brand tint - and a touch bolder so it reads as "do this", not a state */ - .badge-do { - @apply rounded-lg px-[7px] py-[3px] text-xs font-semibold; - background: var(--brand-50); - color: var(--brand-700); - flex: none; - } - .badge-qa { background: var(--badge-purple-bg); color: var(--badge-purple-fg); } .badge-cr { background: var(--badge-gray-bg); color: var(--badge-gray-fg); } .badge-blocked { background: var(--badge-amber-bg); color: var(--badge-amber-fg); } /* deploy hold: deliberate, not alarming — gray, not amber */ diff --git a/frontend-v2/src/views/Classic.tsx b/frontend-v2/src/views/Classic.tsx index 1899db19..c9ba232e 100644 --- a/frontend-v2/src/views/Classic.tsx +++ b/frontend-v2/src/views/Classic.tsx @@ -1,8 +1,7 @@ import { crDone, qaDone, type DerivedPull } from '../model/status'; import type { PullData } from '../types'; -import { ago, closedEpoch, pullKey } from '../format'; -import { ClosedBadge, EmptyState, RepoRef } from '../components/bits'; -import { CardShell } from '../components/Card'; +import { closedEpoch, pullKey } from '../format'; +import { EmptyState } from '../components/bits'; import { BoardColumn } from '../components/Column'; import { laneShown, Truncated } from '../components/Lane'; import { WordGroupRows } from '../components/WordGroups'; @@ -80,36 +79,16 @@ function Column({ ); } -/** The closed card shares the column card's two-zone anatomy. */ -function ClosedCard({ pull }: { pull: PullData }) { - const merged = !!pull.merged_at; - return ( - - - - - {ago(closedEpoch(pull))} ago - - - } - /> - ); -} - /** v1's Recently Closed panel, honored for ?closed=1 bookmarks. */ -function ClosedColumn({ pulls }: { pulls: PullData[] }) { +function ClosedColumn({ pulls, opts }: { pulls: PullData[]; opts: RowOptions }) { const ordered = [...pulls].sort((a, b) => closedEpoch(b) - closedEpoch(a)); return ( - {ordered.map(p => ( - - ))} + + {ordered.map(p => ( + + ))} + ); } @@ -178,7 +157,7 @@ export function Classic({ defaultOpen={!collapsed?.has(id)} /> ))} - {closed && } + {closed && }

); } diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx index 2215ff86..e3f48995 100644 --- a/frontend-v2/src/views/People.tsx +++ b/frontend-v2/src/views/People.tsx @@ -246,7 +246,7 @@ export function People({
{selectedPerson && ( Nothing open from your team right now.

+ )} {codeRegions.length > 0 && regionMatches.length > 0 && ( From 467381f35e9f06127839e30386a5932bb6eaa448 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 21:22:30 -0700 Subject: [PATCH 218/381] Let your own pulls show up in your team's view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit teamBuckets excluded the author's own pulls from every bucket, including `rest`, so if you added yourself to your own team, your work just vanished instead of appearing anywhere. The exclusion was right for `reviewable`/`stamped` (you can't review your own PR, so it can never be a re-stamp or CR waiting on you) but wrong for `rest`, which is just "team work, whatever its state" — your own PRs belong there. Split the filter: `theirs` now includes your own pulls, and a separate reviewableByMe() guard keeps them out of reviewable/stamped specifically. TeamPicker marks your own row "(you)" so it is obvious why ticking yourself does something different from ticking a teammate. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/TeamPicker.tsx | 18 ++++++++++++------ frontend-v2/src/model/team.test.ts | 6 ++++-- frontend-v2/src/model/team.ts | 22 ++++++++++++++++------ 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/frontend-v2/src/components/TeamPicker.tsx b/frontend-v2/src/components/TeamPicker.tsx index 8b2e16a5..acd6bed3 100644 --- a/frontend-v2/src/components/TeamPicker.tsx +++ b/frontend-v2/src/components/TeamPicker.tsx @@ -15,11 +15,13 @@ function CandidateRow({ login, count, checked, + self, onToggle, }: { login: string; count?: number; checked: boolean; + self?: boolean; onToggle: () => void; }) { return ( @@ -34,6 +36,7 @@ function CandidateRow({ {login} + {self && (you)} {count != null && ( {count || ''} @@ -43,11 +46,11 @@ function CandidateRow({ } /** Every author and signer on the board — the login universe a teammate can - * be picked from — with an open-PR count for sorting. Bots and yourself are - * excluded: you're implicitly on your own team, and a dependency bot never - * reviews anything. */ + * be picked from — with an open-PR count for sorting. Bots are excluded (a + * dependency bot never reviews anything); you are not — some people want their + * own PRs to ride along in the Team view, so you can tick yourself. */ function useCandidates(extraBots: ReadonlySet) { - const { pulls, me } = usePulldasher(); + const { pulls } = usePulldasher(); return useMemo(() => { const counts = new Map(); const known = new Set(); @@ -60,10 +63,10 @@ function useCandidates(extraBots: ReadonlySet) { } } return [...known] - .filter(login => login !== me && !isBotLogin(login, extraBots)) + .filter(login => !isBotLogin(login, extraBots)) .sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0) || a.localeCompare(b)) .map(login => ({ login, count: counts.get(login) ?? 0 })); - }, [pulls, me, extraBots]); + }, [pulls, extraBots]); } /** @@ -75,6 +78,7 @@ function useCandidates(extraBots: ReadonlySet) { */ export function TeamPicker({ extraBots = EMPTY_BOTS }: { extraBots?: ReadonlySet }) { const { myTeam } = useSettings(); + const { me } = usePulldasher(); const candidates = useCandidates(extraBots); const [query, setQuery] = useState(''); @@ -96,6 +100,7 @@ export function TeamPicker({ extraBots = EMPTY_BOTS }: { extraBots?: ReadonlySet key={login} login={login} checked + self={login === me} onToggle={() => toggleTeammate(login, false)} /> ))} @@ -113,6 +118,7 @@ export function TeamPicker({ extraBots = EMPTY_BOTS }: { extraBots?: ReadonlySet login={c.login} count={c.count} checked={false} + self={c.login === me} onToggle={() => toggleTeammate(c.login, true)} /> ))} diff --git a/frontend-v2/src/model/team.test.ts b/frontend-v2/src/model/team.test.ts index 1681ad60..9ec01827 100644 --- a/frontend-v2/src/model/team.test.ts +++ b/frontend-v2/src/model/team.test.ts @@ -38,10 +38,12 @@ describe('teamBuckets — what a team-authored pull sorts into', () => { expect(reviewable).toEqual([p]); }); - it('excludes a pull authored by me, even if I ended up in the member list', () => { + it('keeps my own pull out of reviewable/stamped but lands it in rest (self on team)', () => { + // you can't review your own PR, so it never enters reviewable/stamped — + // but adding yourself should still surface your work, in rest, not drop it const p = dp({ author: 'me', status: 'needs_cr' }); const buckets = teamBuckets([p], ['me'], 'me'); - expect(buckets).toEqual({ reviewable: [], stamped: [], rest: [] }); + expect(buckets).toEqual({ reviewable: [], stamped: [], rest: [p] }); }); it('moves a pull to stamped once I hold a CR stamp on it', () => { diff --git a/frontend-v2/src/model/team.ts b/frontend-v2/src/model/team.ts index da77c9c2..cbc0c1b9 100644 --- a/frontend-v2/src/model/team.ts +++ b/frontend-v2/src/model/team.ts @@ -18,9 +18,10 @@ export interface TeamBuckets { * went stale — the PR is back to 0-of-1 and still needs a CR, reviewable by * you (a re-stamp) or by anyone else — so it belongs in `reviewable`, not * `stamped`. (The row's own note still reads "your move: Re-stamp"; the lane - * just must not claim it's waiting on someone else.) The author being you is - * excluded everywhere, even if `me` ends up in `members` by mistake — you - * can't review your own PR. + * just must not claim it's waiting on someone else.) You can't review your own + * PR, so your pulls stay out of `reviewable`/`stamped` — but they still land in + * `rest`, so adding yourself to your team surfaces your work instead of + * dropping it (the same way People.tsx handles viewing your own page). */ export function teamBuckets( pulls: DerivedPull[], @@ -28,15 +29,24 @@ export function teamBuckets( me: string | null ): TeamBuckets { const memberSet = new Set(members); - const theirs = pulls.filter(p => memberSet.has(p.data.user.login) && p.data.user.login !== me); + // include your own pulls in the pool; you just can't *review* them, so they + // fall through to `rest` below instead of vanishing when you add yourself + const theirs = pulls.filter(p => memberSet.has(p.data.user.login)); + const reviewableByMe = (p: DerivedPull) => p.data.user.login !== me; const reviewable = crSort( theirs.filter( - p => ['needs_cr', 'needs_recr'].includes(p.status) && !p.crBy.includes(me ?? '') + p => + reviewableByMe(p) && + ['needs_cr', 'needs_recr'].includes(p.status) && + !p.crBy.includes(me ?? '') ) ); const stamped = theirs.filter( - p => ['needs_cr', 'needs_recr'].includes(p.status) && p.crBy.includes(me ?? '') + p => + reviewableByMe(p) && + ['needs_cr', 'needs_recr'].includes(p.status) && + p.crBy.includes(me ?? '') ); const inLane = new Set([...reviewable, ...stamped]); const rest = theirs From 59eae037b2d40ee92d92b830a5d8c28d27a97485 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 21:23:55 -0700 Subject: [PATCH 219/381] Promote drafts to a first-class filter control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drafts toggle used to live buried under the Repos popover's "This session" fold, alongside the unrelated cryo checkbox — an easy control to never notice, for a filter that meaningfully changes what's on the board. Add DraftsFilter.tsx: its own trigger on the filter row, active only when it differs from the Settings default, with the mode picker one click away instead of two. Fix a real bug found while moving it: 'mine' mode hid every draft that wasn't your own, including a draft GitHub explicitly requested you review — so the reviewer it was requested from couldn't see it without switching to 'all' first. The board's draftsMode filter (app.tsx) and the legacy /v1 board's own draft rule (legacy.ts) both now keep a draft visible when reviewRequestedFrom() says GitHub asked you specifically, in addition to the existing "it's your own draft" exception. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 25 +++++-- .../src/components/filters/DraftsFilter.tsx | 75 +++++++++++++++++++ .../src/components/filters/RepoFilter.tsx | 53 +++++-------- frontend-v2/src/legacy.ts | 8 +- 4 files changed, 122 insertions(+), 39 deletions(-) create mode 100644 frontend-v2/src/components/filters/DraftsFilter.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 8294447f..6c30d679 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -25,6 +25,7 @@ import { useNotifications } from './notifications'; import { ToastStack, useToasts } from './toasts'; import { matchesQuery } from './model/query'; import { CRYO_KEY, isBotLogin, personHidden, repoHidden } from './model/visibility'; +import { reviewRequestedFrom } from './model/reviewers'; import { foldDomId, openFold } from './components/Lane'; import { Legend } from './components/Legend'; import { Logo } from './components/Logo'; @@ -33,6 +34,7 @@ import { RepoFilter } from './components/filters/RepoFilter'; import { PeopleFilter } from './components/filters/PeopleFilter'; import { WeightFilter } from './components/filters/WeightFilter'; import { StateFilter } from './components/filters/StateFilter'; +import { DraftsFilter } from './components/filters/DraftsFilter'; import { FilterChips, hasActiveFilters } from './components/filters/FilterChips'; import { SavedFiltersInput, SavedFiltersMenu } from './components/SavedFiltersPanel'; import type { RowOptions } from './components/Row'; @@ -492,10 +494,20 @@ export function App() { // no matter whose work you follow (they land in the bots fold, not lanes) if (scope.authors.length) out = out.filter(p => isBot(p) || scope.authors.includes(p.data.user.login)); - // drafts: 'mine' hides other people's drafts, your own always show. - // The legacy path owns its own draft rule, so don't double-apply. + // drafts: 'mine' keeps other people's drafts quiet on the general board, + // but not when you've deliberately looked (scoped/`author:`d that person) + // or when GitHub explicitly requested your review — a review-requested + // draft was vanishing even from the reviewer it was requested from, the + // reported bug. Your own drafts always show. The legacy path owns its own + // draft rule, so don't double-apply. if (!legacy && draftsMode === 'mine') - out = out.filter(p => !p.data.draft || p.data.user.login === me); + out = out.filter( + p => + !p.data.draft || + p.data.user.login === me || + revealedAuthor(p.data.user.login) || + reviewRequestedFrom(p, me) + ); if (query) out = out.filter(p => matchesQuery(p, query, me)); return out; }, [ @@ -887,8 +899,6 @@ export function App() { showAll={showAll} setShowAll={setShowAll} cryoCount={cryoCount} - draftsMode={draftsMode} - setDraftsMode={setDraftsMode} scope={scope} setScope={setScope} /> @@ -899,6 +909,11 @@ export function App() { setWeightSel={setWeightSel} /> + void; + /** the Settings default this session value overrides */ + defaultMode: 'mine' | 'all'; +}) { + const active = draftsMode !== defaultMode; + const summary = active ? `Drafts · ${draftsMode}` : 'Drafts'; + + return ( +
+ ( + + )} + > +
+ Show drafts + +
+

+ Your own drafts always show. “All” adds everyone else’s. +

+
+
+ ); +} diff --git a/frontend-v2/src/components/filters/RepoFilter.tsx b/frontend-v2/src/components/filters/RepoFilter.tsx index d7596258..49889ea8 100644 --- a/frontend-v2/src/components/filters/RepoFilter.tsx +++ b/frontend-v2/src/components/filters/RepoFilter.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { CRYO_KEY, repoState } from '../../model/visibility'; import { shortRepo } from '../../format'; import { setRepoPref, togglePrimaryRepo, useSettings } from '../../settings'; -import { QuietButton, Segmented } from '../bits'; +import { QuietButton } from '../bits'; import { Popover } from '../Popover'; import { FilterSearch, OnlyButton } from './shared'; @@ -12,10 +12,10 @@ import { FilterSearch, OnlyButton } from './shared'; * you had to make before you could even search). Scope (which repos are on * your board right now) and mute (which repos are off your board, period) * live side by side on each row, plus the ★ star that marks a repo you - * actively review (drives the review queue's primary/other split). The two - * session-only toggles that used to be their own "Drafts" tab — cryo and - * drafts mode — ride along at the bottom under a "This session" label, so - * they read as visibly less durable than the rows above them. + * actively review (drives the review queue's primary/other split). The cryo + * session toggle rides along at the bottom under a "This session" label, so it + * reads as visibly less durable than the rows above it. (Drafts used to sit + * here too; it's now its own control on the filter row — see DraftsFilter.) */ export function RepoFilter({ repos, @@ -25,8 +25,6 @@ export function RepoFilter({ showAll, setShowAll, cryoCount, - draftsMode, - setDraftsMode, scope, setScope, }: { @@ -39,8 +37,6 @@ export function RepoFilter({ showAll: boolean; setShowAll: (next: boolean) => void; cryoCount: number; - draftsMode: 'mine' | 'all'; - setDraftsMode: (m: 'mine' | 'all') => void; scope: { repos: string[]; authors: string[] }; setScope: (next: { repos: string[]; authors: string[] }) => void; }) { @@ -191,12 +187,11 @@ export function RepoFilter({ )} > - {filteredShown.map(r => shownRow(r.name, r.count))} - {filteredShown.length === 0 && ( -
No repos match.
- )} + {/* muted & org-hidden ("drafts") ride at the top, collapsed, so the + one-click reveal is the first thing you reach — matching the + Settings repo manager's muted→hidden→shown order */} {filteredHidden.length > 0 && ( -
+
Muted & org-hidden ({filteredHidden.length}) - ) : ( - - {login.slice(0, 2)} + {!broken && ( + setBroken(true)} + /> + )} ); } -const WEIGHT_WORD: Record = { +/** The hover card behind a clickable avatar: the picture bigger, the handle, + * and a jump to their GitHub profile. (A real name would need a server-side + * user fetch — it isn't on our wire — so we show the handle we have.) */ +function PersonCard({ login }: { login: string }) { + return ( + + ); +} + +export function Avatar({ + login, + size = 22, + onClick, +}: { + login: string; + size?: number; + onClick?: (login: string) => void; +}) { + if (!onClick) { + return ( + + + + ); + } + // A clickable avatar keeps its one-click "filter to this person" gesture + // (the trigger's own onClick), and grows a hover preview card on top — the + // same hover-open/pin discipline AgeStamp and the ledger use. Hover is a + // supplement (the picture bigger + a GitHub link); the click action stays + // fully keyboard- and touch-reachable. + return ( + ( + + )} + > + + + ); +} + +export const WEIGHT_WORD: Record = { XS: 'very light', S: 'light', M: 'medium', diff --git a/frontend-v2/src/format.ts b/frontend-v2/src/format.ts index 09667aca..10fb5c05 100644 --- a/frontend-v2/src/format.ts +++ b/frontend-v2/src/format.ts @@ -8,13 +8,27 @@ export function ago(epochSecs: number, now: number = Date.now() / 1000) { return `${Math.round(s / 86400)}d`; } -/** Deterministic avatar hue per login (no external images: CSP-safe). */ +/** Deterministic hue per login, the fallback avatar color behind the picture + * (and the whole avatar for bots / deleted accounts that have no picture). */ export function loginHue(login: string) { let h = 0; for (const c of login) h = (h * 31 + c.charCodeAt(0)) % 360; return h; } +/** GitHub serves a public, unauthenticated avatar for any login at + * github.com/.png (v1 used the same). Ask for 2× the render size so it + * stays crisp on retina. Bots and deleted accounts 404 here, so the caller + * falls back to the login's initials on error. */ +export function githubAvatarUrl(login: string, px: number) { + return `https://github.com/${encodeURIComponent(login)}.png?size=${Math.round(px * 2)}`; +} + +/** A person's GitHub profile page. */ +export function githubProfileUrl(login: string) { + return `https://github.com/${encodeURIComponent(login)}`; +} + export function githubUrl(repo: string, number: number) { return `https://github.com/${repo}/pull/${number}`; } From ae08f62040c7556f45ec7f8661259a981f29ac4a Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 21:24:53 -0700 Subject: [PATCH 222/381] Give the header its own responsive floating layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header used to stack logo, connection dot, refresh progress, open count, v1 link, notifications, legend, and settings into one flex-wrap row above the tab strip — on a narrow viewport it wrapped unpredictably and ate vertical space. Rebuild it: the logo and the right-hand control cluster both float (absolute, z-[1]) out past the centered content into the page gutters, capped at a 1536px content width so an ultrawide display parks them a fixed ~150px out instead of flinging them to the screen edges. Below 2xl the wordmark collapses to just the mark, and the open-count/ v1-link hide, so the floats never collide with the tab row's own reserved padding. The legend drops entirely below sm — it's a reference, not an action, and phones need the room. The tab strip picks up a border-t to read as its own row under the logo/controls line instead of implying it's part of the same cluster. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 150 ++++++++++++++++++++++------------------ 1 file changed, 84 insertions(+), 66 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 6c30d679..21ecf2a8 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -819,78 +819,96 @@ export function App() { <>
-
+ {/* Logo + lenses share one line. The logo and the right-hand + controls both float out past the centered content into the + gutters — but only as far as a 1536px cap, so an ultrawide + display parks them a fixed ~150px outside the content instead of + flinging them to the screen edges. The wordmark collapses to the + mark, and the open-count / v1 link hide, once the gutter is too + tight (below 2xl); the tabs reserve pl/pr for the floats below + that so nothing collides. The floats carry z-[1] so they stay + clickable over the tab row's own box. */} +
{/* the page's one h1 — heading navigation needs a root, and every lane h2 needs a parent level */} -

+

- pulldasher + + pulldasher +

- - live updates {connection} - - {refreshProgress && ( - // no spinner, no color — the changing number is the motion, - // same wording Settings' Data group shows for the same state - - {refreshProgress.done === refreshProgress.total - ? `refreshed ${refreshProgress.total}` - : `refreshing ${refreshProgress.done} of ${refreshProgress.total}`} + {/* inset-y-0 + items-center for vertical centering, NOT + -translate-y-1/2: a transform on this ancestor would re-base + position:fixed for the Settings drawer / popovers rendered + under it, leaving them mispositioned (invisible). */} +
+ {refreshProgress && ( + // no spinner, no color — the changing number is the motion, + // same wording Settings' Data group shows for the same state + + {refreshProgress.done === refreshProgress.total + ? `refreshed ${refreshProgress.total}` + : `refreshing ${refreshProgress.done} of ${refreshProgress.total}`} + + )} + + live updates {connection} - )} - - - {isScoped ? `${scoped.length} of ${pulls.length}` : pulls.length} - {' '} - open - - - - v1 board - - - - + + + {isScoped ? `${scoped.length} of ${pulls.length}` : pulls.length} + {' '} + open + + + v1 board + + + {/* the symbol legend is a reference, not an action — drop it + on phones to give the header controls their room back */} + + + + +
+
+ {/* min-w-0 + overflow-x-auto (no-scrollbar in styles.css) + turns a too-narrow tab strip into a swipe rather than a + wrap ("My work 5" splitting) or a page-widening overflow. */} + +
-
- {/* min-w-0 lets this flex item shrink below its tabs' combined - min-content width; without it, the six-tab row would force - the whole page wider than the viewport instead of scrolling - internally. overflow-x-auto + no-scrollbar (styles.css) - turns the overflow into a swipeable tab strip rather than - letting it wrap ("My work 5" splitting across two lines) or - push the page sideways. */} - +
Date: Tue, 21 Jul 2026 21:25:14 -0700 Subject: [PATCH 223/381] Shrink the star hit-box bleed so it stops overlapping neighbors .hit extends a small control's tap target with an absolutely-positioned ::after at -5px inset, without moving the visible box. But the star toggle rows (repo list, people list) put Mute/Only buttons a gap-2 (8px) apart from the star, so a -5px bleed on each side reached 10px into that gap and the two controls' hit areas started stealing each other's clicks. Tighten .hit's inset to -4px (still enough to lift a 16px avatar to the 24px tap-target floor) and drop the extra `hit -my-1.5` bleed specifically from the three star toggles (RepoFilter, RepoManager, PeopleFilter), since each star's own py-1.5 padding already clears 24px without it. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/RepoManager.tsx | 5 ++++- frontend-v2/src/components/filters/PeopleFilter.tsx | 4 +++- frontend-v2/src/components/filters/RepoFilter.tsx | 4 +++- frontend-v2/src/styles.css | 8 ++++++-- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/frontend-v2/src/components/RepoManager.tsx b/frontend-v2/src/components/RepoManager.tsx index d5966e5f..a7cc5623 100644 --- a/frontend-v2/src/components/RepoManager.tsx +++ b/frontend-v2/src/components/RepoManager.tsx @@ -103,7 +103,10 @@ export function RepoManagerGroup({ return (
- + + + + boolean; -}) { +function DealButton({ queue, opts }: { queue: DerivedPull[]; opts: RowOptions }) { const { pulls } = usePulldasher(); - const me = opts.me; const [dealtKey, setDealtKey] = useState(null); const [passed, setPassed] = useState>(new Set()); const deal = (passedNow: ReadonlySet) => { - const picked = dealOne(queue, { - me, - pulls, - claims: opts.claims ?? {}, - passed: passedNow, - deprioritize, - }); + const picked = dealFrom(queue, { claims: opts.claims ?? {}, passed: passedNow }); setDealtKey(picked ? pullKey(picked.data) : null); }; @@ -282,8 +275,7 @@ export function Review({ !p.crBy.includes(me) && (p.status === 'needs_cr' || (p.status === 'needs_recr' && !p.recrBy.includes(me))) ); - const aged = crPool.filter(p => p.starved).sort((a, b) => b.starveScore - a.starveScore); - const reviewable = crSort(crPool.filter(p => !p.starved)); + const nonStarved = crPool.filter(p => !p.starved); // Bot PRs (dependency bumps, mostly) are review work too — someone has to // move the daily ones along — just low priority. The reviewable ones join @@ -303,21 +295,26 @@ export function Review({ const botRest = bots.filter(p => !botKeys.has(pullKey(p.data))).sort(bySecurityThenAge); const isDemoted = (p: DerivedPull) => botKeys.has(pullKey(p.data)); - // your review queue leads with your repos, then the day's bot bumps at the - // tail; everything else folds into "other repos" so it's reachable but not in - // the way. Starvation stays cross-repo (the Aging lane) — the fairness - // backstop is deliberately everyone's job. - const queueHumans = starFirst( - reviewable.filter(p => isPrimaryRepo(p.data.repo)), + // ONE review queue: your primary repos' reviewables, every starved pull + // regardless of repo (the fairness backstop rides in the ranking now, not + // a separate Aging lane — starveScore's uncapped age × size term floats + // them to the top numerically instead of positionally), and the day's bot + // bumps sinking to the tail. Ranked by the exact score Deal me one uses, + // so the button always deals the top visible card — the list and the + // button can't disagree. Non-starved work outside your primary repos still + // folds into "other repos" below, reachable but not in the way. + const queue = starFirst( + dealRank( + [ + ...nonStarved.filter(p => isPrimaryRepo(p.data.repo)), + ...crPool.filter(p => p.starved), + ...botReviewable, + ], + { me, pulls, deprioritize: isDemoted, warnDays: opts.ageWarnDays } + ), starred ); - const queue = [...queueHumans, ...botReviewable]; - const queueOther = reviewable.filter(p => !isPrimaryRepo(p.data.repo)); - - // Deal me one draws from the whole reviewable pool — your primary repos, the - // aging cross-repo backstop, and bots — so a triage run doesn't skip the - // stuff that quietly needs moving. Bots sink to the end of the pick. - const dealPool = [...queueHumans, ...aged, ...botReviewable]; + const queueOther = crSort(nonStarved.filter(p => !isPrimaryRepo(p.data.repo))); // Ready-to-merge is the author's button, not the reviewer's job: a count // in the rest group, not a lane at the top. @@ -458,12 +455,16 @@ export function Review({ // a quiet board (nothing in any primary lane) is exactly when the rest // group's folds become the main event — they should greet you open, not // as a wall of closed triangles + // the per-card "why" behind the repo# door in the ranked lanes — the same + // reason strings the dealt card's footnote and the Start-here toast use, + // so every surface explains a pick in the same words + const whyUpNext = (p: DerivedPull) => startHereReason(p, pulls, me); + const boardIsQuiet = !yourMove.length && !yoursWaiting.length && !changed.length && !queue.length && - !aged.length && !needsQa.length; // the rest group itself earns a title only when it has something inside — @@ -548,30 +549,58 @@ export function Review({ {/* below here is offered work, not owed work — the board's suggestion for what to pick up next, as distinct from "Your move" above. The label only earns its place when something is actually on offer. */} - {(aged.length > 0 || queue.length > 0 || needsQa.length > 0) && ( + {(queue.length > 0 || needsQa.length > 0) && (
Pick up next
)} - ( + + )} + > +
+

One score ranks every card:

+

+ pulls open {opts.ageWarnDays ?? 4}+ days without a full CR float to the top, + hardest-starved first (age × size) — even heavy ones, even outside your + primary repos. +

+

+ Then: repos you’ve stamped before, authors who’ve reviewed yours, and small + quick wins, lightest first. Starred repos pin to the top; bot bumps sink to + the tail. +

+

“Deal me one” deals the top card that isn’t claimed or passed.

+
+ + } pulls={queue} - cap={9} - opts={opts} - headerExtra={} + cap={12} + opts={{ ...opts, rankReason: whyUpNext }} + headerExtra={} /> {restTotal > 0 && ( @@ -608,6 +637,7 @@ export function Review({ dot={STATUS_DOT.dev_block} count={devBlocked.length} label="dev blocked" + hint="paused by the author — nothing to review yet" id="review:dev-blocked" > @@ -643,6 +673,7 @@ export function Review({ dot={STATUS_DOT.ci_red} count={ciRed.length} label="CI red" + hint="the author fixes CI first" id="review:ci-red" defaultOpen={boardIsQuiet} > @@ -652,6 +683,7 @@ export function Review({ dot={STATUS_DOT.draft} count={drafts.length} label={drafts.length === 1 ? 'draft' : 'drafts'} + hint="not up for review yet" id="review:drafts" > From 238841a02a8f42c873be86eb704d2620d8690798 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 21:42:10 -0700 Subject: [PATCH 229/381] Document the age strip and one-queue doctrine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the decisions behind the queue merge so they survive the session: the age strip's row in the rail's shape taxonomy (gated, amber/red only, square caps — and the rule that a relocated signal must be removed from its old mark in the same change), "a button that acts on a list acts on the list the user sees", and the three quiet layers a ranked lane uses to explain itself. Co-Authored-By: Claude Opus 4.8 --- DESIGN.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index 1daf7139..6d5f8e06 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -71,6 +71,20 @@ contents, popover previews. Same element order, same marks, same doors. A context may *add* (a "why this one" footnote, claim buttons) but never reorder or restate — inline status text on a card is always a regression. +**A button that acts on a list acts on the list the user sees.** The review +queue is ranked by the same score Deal-me-one deals from, so the button +always takes the top visible card. Two orderings for one list — one shown, +one hidden inside a button — is a least-surprise bug even when both are +individually sensible; if a pick needs extra signals, put them in the +lane's ranking and explain them behind the sub-line. + +**Ranked lanes explain themselves in three quiet layers**: the sub-line +says the ordering in one plain sentence (and is itself the hover-door to +the full story — existing text becomes interactive, no info-icon chrome); +each card's state popover carries a "why it's up next" line; and every +fold's hint says what lands in it. If a user has to ask why a card is +where it is, one of these layers is missing. + ## Identity vs standing A mark has two layers: the **glyph names what it is; the treatment names its @@ -92,6 +106,12 @@ member of the sign-off family (it did, when weight was a dot): | circle-checks (14px SVG masks, `.pip`) | CR/QA sign-off | the rail's senior elements; nothing else may match their size | | segmented horizontal bar | CI | red/slate segments sized by count; **passed is invisible at rest**, green revealed on hover | | horizontal ratio strip (4px, under CR+QA) | review weight | one quiet ink fill on a fixed-extent track; fill **doubles per class** (6/13/25/50/100%) because effort doubles per class — exponential honesty beats linear prettiness | +| horizontal ratio strip (4px, under CI) | age / starvation | **gated, not always-on**: renders nothing below the aging threshold (a placeholder holds the slot), then an amber fill growing warn→rot, red past rot, plateauing there. Distinct from the weight strip on four axes — left column, gated, only ever amber/red, square caps vs pill. The numeral in the meta line stays neutral ink (weight bump only): one mark carries the color | + +The rail is two columns × two decks: `CI / age-strip` on the left, +`CR+QA pips / weight-strip` on the right. Age's color lives *only* in its +strip — when a signal moves onto a better mark, remove it from the old one +in the same change, or the card gets louder instead of clearer. Marks are drawn as SVG masks / CSS geometry, never font glyphs — a text ✓ at 10px is at the mercy of the platform rasterizer (a struck-through ✓ was From 544e3c154422fa567a74f22b966b8d29fb6ff509 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 22:32:18 -0700 Subject: [PATCH 230/381] Unify the rail: CI joins the marks, age moves to the baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three co-designed changes that are really one: every signal on a card is now a labeled stamp, a quiet ratio, or silence. CI wears the sign-off family's anatomy (label + circle mark) because the machine is a reviewer too: a red X disc + failing count is the only at-rest state; passing/running render invisibly in a reserved slot and reveal on row hover — the segmented bar's "green only on hover" physics, one vocabulary instead of two shapes. This also kills the measured rail zig-zag (the failing numeral had no reserved slot) and the lone-red-nub private chip. Age leaves the rail for the row's own bottom edge: an amber 2px line, left-anchored, growing toward the rot day and plateauing. Amber ONLY — the red plateau shipped for a day and a live review measured it as the #1 thing the eye landed on board-wide, reading as broken CI (same hue, same column, same bar shape). Red belongs to CI alone again. The day count floats right in the meta line as a quiet timestamp column, capping the track; it carries font weight, never color. The rail collapses to one instrument: CI · CR · QA marks, weight strip beneath. Legend and Settings copy updated in the same change. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Card.tsx | 7 ++ frontend-v2/src/components/Legend.tsx | 48 +++-------- frontend-v2/src/components/Row.tsx | 57 ++++++------- frontend-v2/src/components/Settings.tsx | 6 +- frontend-v2/src/components/bits.tsx | 101 +++++++++--------------- frontend-v2/src/styles.css | 26 ++++++ frontend-v2/src/views/Review.tsx | 10 +-- 7 files changed, 115 insertions(+), 140 deletions(-) diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index 0d66abb3..f5759ec0 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -57,6 +57,7 @@ export function CardShell({ stretch = true, compact = false, avatarBadge, + edge, depth = 0, }: { login: string; @@ -82,6 +83,10 @@ export function CardShell({ * author ★) — a slot rather than an Avatar prop, so this stays a one- * caller concern instead of touching every Avatar call site. */ avatarBadge?: ReactNode; + /** a row-edge overlay (the age baseline): absolutely positioned against + * the pd-row (already relative), rendered last so it paints over the + * divider without entering the flex flow. */ + edge?: ReactNode; /** stack-nesting depth (0 = top-level): indents the row and shows a * connector elbow before the avatar — model/stack.ts's groupIntoTree * supplies it. Capped at 2 by the model; the geometry doesn't need its @@ -117,6 +122,7 @@ export function CardShell({ {meta} {rail}
+ {edge}
); } @@ -146,6 +152,7 @@ export function CardShell({ height becomes deliberate space around the marks instead of dead air above trailing chips */} {rail} + {edge}
); } diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 5665f130..666c57fa 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -1,24 +1,8 @@ import type { ReactNode } from 'react'; import { useSettings } from '../settings'; -import { AgeStrip, WeightMeter } from './bits'; +import { WeightMeter } from './bits'; import { Popover } from './Popover'; -/** a bare CI bar for the legend, without the popover/button chrome the real - * CiStatus trigger carries — same segment shape and colors, just static. */ -function CiBarSample({ segments }: { segments: { background: string; opacity?: number }[] }) { - return ( - - {segments.map((s, i) => ( - - ))} - - ); -} - /** a keycap, sized to the legend's small type */ function Kbd({ children }: { children: ReactNode }) { return ( @@ -129,20 +113,14 @@ export function Legend() { - - + } - def={`age — the strip under the CI bar stays invisible until a pull is ${s.ageWarnDays}+ days old without full review, then fills amber toward ${s.ageRotDays} days and turns red past it. The day count next to the repo# just gets bolder, and hovering it shows both clocks`} + def={`age — an amber line along a row's bottom edge appears once it's ${s.ageWarnDays}+ days without full review and grows to the right; reaching full width means ${s.ageRotDays}+ days. The quiet day count at the row's right edge caps it and just gets bolder — hover it for both clocks`} /> + + CI + + + } - def="CI: a proportional bar, red = failing, gray-blue = still running. Passed checks show nothing at rest — no news is good news; hover to reveal the green and the per-check list" + def="CI wears the same marks as CR and QA — the machine is a reviewer too. A red ✗ disc means failing checks (the count beside it says how many); the gray-blue ring means still running. Passing shows nothing at rest — no news is good news; hover a row for its quiet green check and the per-check list" /> - {/* the rail's left deck: CI on top, the age strip under it — the - same two-deck geometry as the sign-off column beside it. The - strip stays invisible until the pull crosses the aging - threshold, so a healthy young row shows nothing here. */} - - - - - {/* weight rides UNDER the whole sign-off section (CR + QA) as a - ratio strip — it's "how heavy is this review", both halves, not - its own rail slot. The shared extent normalizes the track length - and gives the doubling fill real resolution. */} + {/* one instrument: the three reviewers' marks in a row — machine + first, then the humans — with the weight strip underneath. CI is + invisible at rest unless failing (revealed on row hover), in a + reserved slot so nothing shifts. Age lives on the row's + baseline, not in the rail. */} + - {regions.length > 0 && ( // a neutral chip, only the ◆ in brand: region-match is soft // personalization, not urgency — a filled brand chip diluted @@ -818,9 +798,30 @@ function RowImpl({ + {/* the age numeral floats right, capping the baseline track — + a quiet timestamp column, mail-client style */} + + + } rail={} + edge={ + + } /> ); } diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 7c98aaf1..74236895 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -414,8 +414,8 @@ export function Settings({ /> set({ ageWarnDays })} /> - + ciRank(a) - ciRank(b) || a.data.context.localeCompare(b.data.context) ); - if (!checks.length) return ; + if (!checks.length) return ; const failing = checks.filter(isRedCheck).length; const passing = checks.filter(c => c.data.state === 'success').length; @@ -352,17 +353,6 @@ export function CiStatus({ pull }: { pull: DerivedPull }) { : pending ? `CI running · ${passing} of ${checks.length} passed` : `CI passed · ${n(checks.length, 'check')}`; - // failing, then pending, then passing. Passed is invisible until you hover - // the bar (no news is good news — an all-green board shows NO bar at rest, - // and a mixed bar shows only its red/slate trouble); each segment's share - // of the bar is its own count, with a 3px floor so one failure among - // twenty checks stays visible - const segments = [ - { count: failing, background: 'var(--bad)' }, - { count: pendingCount, background: 'var(--slate)' }, - { count: passing, background: 'var(--ok)', quiet: true }, - ].filter(s => s.count > 0); - return ( 0 + ? '' + : 'opacity-0 transition-opacity duration-150 hover:opacity-100 focus-visible:opacity-100 [.pd-row:hover_&]:opacity-100 motion-reduce:transition-none' + }`} > - - {segments.map((s, i) => ( - - ))} + + CI + 0 ? 'pip-fail' : pending ? 'pip-run' : 'pip-on'}`} + /> {failing > 0 && ( ; - const span = Math.max(rotDays - warnDays, 1); - const pct = 6 + Math.min((ageDays - warnDays) / span, 1) * 94; - const rotted = ageDays >= rotDays; - return ( - - - - ); + if (quiet || ageDays < warnDays) return null; + const pct = Math.min((ageDays - warnDays) / Math.max(rotDays - warnDays, 1), 1); + return ; } /** * The age slot: hours under a day, then days, with both clocks in the * popover. Hours matter here: in three months of real history, 62% of * pulls merged same-day, so "0d" was a dead signal for most of the live - * board. The urgency *color* lives in the rail's AgeStrip now — this + * board. The urgency *color* lives on the row's AgeBaseline now — this * numeral stays neutral ink and only gains font weight past the same * thresholds, so age's salience is carried once, by the mark built for * continuous gradation, not twice. diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 39b68547..f91978b0 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -215,9 +215,35 @@ label:has(> input:not(:disabled)) { --pip-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.6' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E"); background-color: var(--ink-3); } + /* CI's marks, same family: the machine is a reviewer too. A solid disc + with a knocked-out X = checks failing (red's ONLY meaning on the board); + the "still running" ring reuses the needed-ring shape in the pending + slate. Passing renders pip-on, revealed only on row hover. */ + .pip-fail { + --pip-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cdefs%3E%3Cmask id='x'%3E%3Crect width='16' height='16' fill='white'/%3E%3Cpath d='M5.4 5.4l5.2 5.2M10.6 5.4l-5.2 5.2' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'/%3E%3C/mask%3E%3C/defs%3E%3Ccircle cx='8' cy='8' r='7.4' fill='white' mask='url(%23x)'/%3E%3C/svg%3E"); + background-color: var(--bad); + } + .pip-run { + --pip-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.6' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E"); + background-color: var(--slate); + } + /* the slot you personally stamped: a dotted rule under the marks */ .pip-mine { border-bottom: 1px dotted var(--ink-3); padding-bottom: 2px; } + /* age as the row's own baseline: an amber line along the bottom edge, + left-anchored, growing with time-without-review toward the rot day and + plateauing there. Amber only — red belongs to CI alone. The quiet day + count floats right in the meta line, capping the track. */ + .pd-age-line { + position: absolute; + left: 0; + bottom: 0; + height: 2px; + background: var(--warn); + pointer-events: none; + } + /* row flags: quiet colored labels, not badges — amber for act-on-it, muted ink for neutral facts. The cluster's hover popover carries meaning. */ .flag-warn { diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index f24682a5..d1fcef0c 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -19,7 +19,6 @@ import { claimFor, claimReview, isFresh, usePulldasher } from '../store'; import type { PullData } from '../types'; import { AgeStamp, - AgeStrip, Avatar, CiStatus, EmptyState, @@ -84,16 +83,9 @@ function DealtCard({
- - - - + Date: Tue, 21 Jul 2026 22:32:18 -0700 Subject: [PATCH 231/381] Update the shape taxonomy for the unified rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two families now, not three: circle marks (CI/CR/QA — label names the reviewer, glyph the verdict, treatment the standing) and ratio strips (weight under the marks; age on the row's baseline, amber-only, gated). Records why the age mark must never borrow red: it shipped that way once and measured as the loudest thing on the board, read as broken CI. Co-Authored-By: Claude Opus 4.8 --- DESIGN.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 6d5f8e06..d7ec850b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -103,15 +103,15 @@ member of the sign-off family (it did, when weight was a dot): | Shape | Metric | Behavior | |---|---|---| -| circle-checks (14px SVG masks, `.pip`) | CR/QA sign-off | the rail's senior elements; nothing else may match their size | -| segmented horizontal bar | CI | red/slate segments sized by count; **passed is invisible at rest**, green revealed on hover | -| horizontal ratio strip (4px, under CR+QA) | review weight | one quiet ink fill on a fixed-extent track; fill **doubles per class** (6/13/25/50/100%) because effort doubles per class — exponential honesty beats linear prettiness | -| horizontal ratio strip (4px, under CI) | age / starvation | **gated, not always-on**: renders nothing below the aging threshold (a placeholder holds the slot), then an amber fill growing warn→rot, red past rot, plateauing there. Distinct from the weight strip on four axes — left column, gated, only ever amber/red, square caps vs pill. The numeral in the meta line stays neutral ink (weight bump only): one mark carries the color | - -The rail is two columns × two decks: `CI / age-strip` on the left, -`CR+QA pips / weight-strip` on the right. Age's color lives *only* in its -strip — when a signal moves onto a better mark, remove it from the old one -in the same change, or the card gets louder instead of clearer. +| circle marks (14px SVG masks, `.pip`) | CI/CR/QA sign-off | ONE family for all three reviewers — the machine is a reviewer too. The label names the reviewer, the glyph names the verdict (✓ approved, ✗ failed, ring = needed/running), the treatment names the standing (solid stands, outlined lapsed). CI is invisible at rest unless failing — its quiet states render in a reserved slot at opacity 0, revealed on row hover, so the reveal can never reflow the line | +| horizontal ratio strip (4px, under the marks) | review weight | one quiet ink fill on a fixed-extent track; fill **doubles per class** (6/13/25/50/100%) because effort doubles per class — exponential honesty beats linear prettiness | +| the row's baseline (2px, bottom edge) | age / starvation | **gated, not always-on**: nothing below the aging threshold, then an amber line growing left→right toward the rot day and plateauing there — a 30-day pull doesn't shout louder than a 10-day one. **Amber only; red belongs to CI alone** (a red age bar shipped once and read as "broken" everywhere). The quiet day count floats right in the meta line, capping the track; it carries no color, only font weight | + +The rail is one instrument: `CI · CR · QA` marks in a row, the weight +strip beneath them. Age lives on the row's own bottom edge, not in the +rail — the track is a line the row already had. When a signal moves onto +a better mark, remove it from the old one in the same change, or the card +gets louder instead of clearer. Marks are drawn as SVG masks / CSS geometry, never font glyphs — a text ✓ at 10px is at the mercy of the platform rasterizer (a struck-through ✓ was From 138a543167cab92c981aecc89cc7536fe1ab6a39 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 22:41:05 -0700 Subject: [PATCH 232/381] Tell the truth in every ranked-lane explanation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four honesty bugs the design review caught in the queue's new explanation layers: the ranking popover said "Starred repos pin to the top" when the mechanism pins starred PEOPLE (and it's a hard override, not a tie-break); "the oldest on your plate" showed on every fallback card when it's true of at most one (now reserved for the single dealt pick via a superlative flag); Needs QA borrowed the deal-score reasons while actually sorted unclaimed-first — it gets its own words; and the region lane's comment claimed matches were "pulled out of the queue/QA lanes" when they triple-listed — now they really are excluded, the lane sits under the Pick-up-next band its own definition puts it in, each region card names the region it matched, and RegionHint stops promising a queue boost that never existed. The "— hover for how" instruction died too: the dotted underline now shows at rest and carries the door itself, per the no-info-icon-chrome rule. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/RegionHint.tsx | 2 +- frontend-v2/src/model/cheers.test.ts | 4 +- frontend-v2/src/model/cheers.ts | 22 ++-- frontend-v2/src/views/Review.tsx | 145 ++++++++++++++-------- 4 files changed, 108 insertions(+), 65 deletions(-) diff --git a/frontend-v2/src/components/RegionHint.tsx b/frontend-v2/src/components/RegionHint.tsx index 55882086..5eb0bcf6 100644 --- a/frontend-v2/src/components/RegionHint.tsx +++ b/frontend-v2/src/components/RegionHint.tsx @@ -28,7 +28,7 @@ export function RegionHint() { Review a particular area? Add code regions in Settings - — PRs touching them lead your queue and gather in their own section. + — PRs touching them gather in their own section above the queue.
- {startHereReason(pull, pulls, me)} + {startHereReason(pull, pulls, me, true)}
@@ -287,31 +287,6 @@ export function Review({ const botRest = bots.filter(p => !botKeys.has(pullKey(p.data))).sort(bySecurityThenAge); const isDemoted = (p: DerivedPull) => botKeys.has(pullKey(p.data)); - // ONE review queue: your primary repos' reviewables, every starved pull - // regardless of repo (the fairness backstop rides in the ranking now, not - // a separate Aging lane — starveScore's uncapped age × size term floats - // them to the top numerically instead of positionally), and the day's bot - // bumps sinking to the tail. Ranked by the exact score Deal me one uses, - // so the button always deals the top visible card — the list and the - // button can't disagree. Non-starved work outside your primary repos still - // folds into "other repos" below, reachable but not in the way. - const queue = starFirst( - dealRank( - [ - ...nonStarved.filter(p => isPrimaryRepo(p.data.repo)), - ...crPool.filter(p => p.starved), - ...botReviewable, - ], - { me, pulls, deprioritize: isDemoted, warnDays: opts.ageWarnDays } - ), - starred - ); - const queueOther = crSort(nonStarved.filter(p => !isPrimaryRepo(p.data.repo))); - - // Ready-to-merge is the author's button, not the reviewer's job: a count - // in the rest group, not a lane at the top. - const ready = others.filter(p => p.status === 'ready'); - // 4. Needs QA is a query, not the status bucket: QA runs in parallel with // CR here (v1's QA column predicate), so anything QA-incomplete with // green CI belongs — not just pulls whose CR is already done. Unclaimed @@ -319,6 +294,8 @@ export function Review({ // claim state, lighter tests first, then oldest. Split by your primary // repos, same as the review queue — QA is the bottleneck on a // self-review team, so it deserves the same relevance cut. + // (moved above the queue/needsQa construction so regionMatches below can + // read both pools before either lane's pool is filtered) const qaPool = others.filter( p => !qaDone(p) && @@ -341,8 +318,61 @@ export function Review({ (b.sizeKnown ? weightRank(b.weight) : 2.5) || b.ageDays - a.ageDays ); - const needsQa = starFirst(qaSort(qaPool.filter(p => isPrimaryRepo(p.data.repo))), starred); - const needsQaOther = qaSort(qaPool.filter(p => !isPrimaryRepo(p.data.repo))); + + // In your code regions: reviewable pulls (CR or QA pool) matching a region + // you set in Settings, deduped across the two pools and genuinely pulled + // out of the queue/QA lanes below (including their "other repos" folds) + // into their own section — the most explicit "this is my area" signal + // earns its own spot instead of a float within the queue. + const regionSeen = new Set(); + const regionMatches = crSort( + [...crPool, ...qaPool].filter(p => { + const k = pullKey(p.data); + if (regionSeen.has(k) || !matchesRegion(p, codeRegions)) return false; + regionSeen.add(k); + return true; + }) + ); + const regionKeys = new Set(regionMatches.map(p => pullKey(p.data))); + + // ONE review queue: your primary repos' reviewables, every starved pull + // regardless of repo (the fairness backstop rides in the ranking now, not + // a separate Aging lane — starveScore's uncapped age × size term floats + // them to the top numerically instead of positionally), and the day's bot + // bumps sinking to the tail. Ranked by the exact score Deal me one uses, + // so the button always deals the top visible card — the list and the + // button can't disagree. Non-starved work outside your primary repos still + // folds into "other repos" below, reachable but not in the way. Region + // matches are excluded here too (same Set-filter pattern as botKeys) — they + // live in their own lane above, not doubled up in the queue. + const queue = starFirst( + dealRank( + [ + ...nonStarved.filter( + p => isPrimaryRepo(p.data.repo) && !regionKeys.has(pullKey(p.data)) + ), + ...crPool.filter(p => p.starved && !regionKeys.has(pullKey(p.data))), + ...botReviewable, + ], + { me, pulls, deprioritize: isDemoted, warnDays: opts.ageWarnDays } + ), + starred + ); + const queueOther = crSort( + nonStarved.filter(p => !isPrimaryRepo(p.data.repo) && !regionKeys.has(pullKey(p.data))) + ); + + // Ready-to-merge is the author's button, not the reviewer's job: a count + // in the rest group, not a lane at the top. + const ready = others.filter(p => p.status === 'ready'); + + const needsQa = starFirst( + qaSort(qaPool.filter(p => isPrimaryRepo(p.data.repo) && !regionKeys.has(pullKey(p.data)))), + starred + ); + const needsQaOther = qaSort( + qaPool.filter(p => !isPrimaryRepo(p.data.repo) && !regionKeys.has(pullKey(p.data))) + ); // your live CR stamp is in, the PR just isn't fully signed off yet (another // reviewer owes a stamp, or a re-CR). Covers needs_recr too, so a PR you @@ -430,20 +460,6 @@ export function Review({ }; yoursWaiting.sort((a, b) => waitRankOf(a) - waitRankOf(b) || b.ageDays - a.ageDays); - // In your code regions: reviewable pulls (CR or QA pool) matching a region - // you set in Settings, deduped across the two pools and pulled out of the - // queue/QA lanes below into their own section — the most explicit "this is - // my area" signal earns its own spot instead of a float within the queue. - const regionSeen = new Set(); - const regionMatches = crSort( - [...crPool, ...qaPool].filter(p => { - const k = pullKey(p.data); - if (regionSeen.has(k) || !matchesRegion(p, codeRegions)) return false; - regionSeen.add(k); - return true; - }) - ); - // a quiet board (nothing in any primary lane) is exactly when the rest // group's folds become the main event — they should greet you open, not // as a wall of closed triangles @@ -452,6 +468,17 @@ export function Review({ // so every surface explains a pick in the same words const whyUpNext = (p: DerivedPull) => startHereReason(p, pulls, me); + // Needs QA's own "why" line: that lane isn't ranked by deal-score, it's + // sorted by qaSort (unclaimed-first, then lightest, then oldest) — reusing + // whyUpNext's reciprocity/quick-win/urgency reasons here would describe a + // ranking this lane doesn't use. + const whyQaNext = (p: DerivedPull) => + p.qaingLogin + ? `${p.qaingLogin} is already testing it — it sinks below unclaimed QA` + : p.sizeKnown && (p.weight === 'XS' || p.weight === 'S') + ? `Nobody's testing it yet — a light one (${p.weight})` + : `Nobody's testing it yet — waiting ${Math.max(1, Math.round(p.ageDays))}d`; + const boardIsQuiet = !yourMove.length && !yoursWaiting.length && @@ -529,21 +556,29 @@ export function Review({ cap={8} opts={opts} /> + {/* below here is offered work, not owed work — the board's suggestion + for what to pick up next, as distinct from "Your move" above. The + label only earns its place when something is actually on offer. */} + {(queue.length > 0 || needsQa.length > 0 || regionMatches.length > 0) && ( +
Pick up next
+ )} {codeRegions.length > 0 && regionMatches.length > 0 && ( { + const r = matchedRegions(p, codeRegions); + return r.length + ? `It touches ${r.join(', ')} — a code region you flagged` + : null; + }, + }} /> )} - {/* below here is offered work, not owed work — the board's suggestion - for what to pick up next, as distinct from "Your move" above. The - label only earns its place when something is actually on offer. */} - {(queue.length > 0 || needsQa.length > 0) && ( -
Pick up next
- )} - one queue, best next review first — hover for how + one queue, best next review first )} > @@ -575,8 +610,8 @@ export function Review({

Then: repos you’ve stamped before, authors who’ve reviewed yours, and small - quick wins, lightest first. Starred repos pin to the top; bot bumps sink to - the tail. + quick wins, lightest first. Pulls from people you’ve starred always lead, + ahead of everything above; bot bumps sink to the tail.

“Deal me one” deals the top card that isn’t claimed or passed.

@@ -592,7 +627,7 @@ export function Review({ sub="CR and QA run in parallel — a pull can sit here and in the queue" pulls={needsQa} cap={6} - opts={{ ...opts, rankReason: whyUpNext }} + opts={{ ...opts, rankReason: whyQaNext }} /> {restTotal > 0 && ( From 6d6b03318905a99e4a951388b47825858e33ea07 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 22:41:06 -0700 Subject: [PATCH 233/381] Keep Stats inside the board's color vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stats cards had quietly re-created the hue overload the color audit killed: WEIGHT_RAMP painted XL diffs broken-red and XS confirmation- green (the exact anti-reference — making heavy work loudest on a board whose users hunt easy pickups); violet, sanctioned only as "QA as a category", colored a time-to-merge series; StarvationCard's numeral doubled the heat its bar already carried; AgeMixCard painted the youngest bucket solid green. Weight is now a monochrome ink-opacity ramp, time-to-merge is slate, numerals are neutral, and age buckets follow the board's own gating: neutral below the warn threshold, amber tiers past it, no red — red belongs to CI alone. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/views/stats/AgeMixCard.tsx | 18 ++++++++++++------ frontend-v2/src/views/stats/StarvationCard.tsx | 3 +-- .../src/views/stats/TimeInReviewCard.tsx | 2 +- frontend-v2/src/views/stats/parts.tsx | 17 +++++++++++------ 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/frontend-v2/src/views/stats/AgeMixCard.tsx b/frontend-v2/src/views/stats/AgeMixCard.tsx index 7ef50048..f90240dd 100644 --- a/frontend-v2/src/views/stats/AgeMixCard.tsx +++ b/frontend-v2/src/views/stats/AgeMixCard.tsx @@ -3,8 +3,10 @@ import { BarRow, StatsCard } from './parts'; /** * How old the open board is, youngest bucket first. Color follows the same - * fresh→amber→red read as the rows' age stamps, so a bottom-heavy chart here - * and a wall of red ages on the board are the same fact. + * fresh→amber read as the rows' age stamps: buckets still under warnDays stay + * quiet ink (young isn't a "confirmation" worth --ok), buckets at/past + * warnDays pick up --warn, and buckets at/past rotDays go full-strength warn + * — never red, since --bad is reserved for broken CI. */ export function AgeMixCard({ buckets, @@ -16,10 +18,14 @@ export function AgeMixCard({ rotDays: number; }) { const max = Math.max(...buckets.map(b => b.count), 1); - // bucket upper bounds, mirroring ageMix's edges - const edges = [0, 2, 6, 13, Infinity]; - const color = (i: number) => - edges[i] >= rotDays ? 'var(--bad)' : edges[i] >= warnDays ? 'var(--warn)' : 'var(--ok)'; + // bucket lower bounds (days), mirroring ageMix's today/1–2d/3–6d/7–13d/14d+ ranges + const lowerBounds = [0, 1, 3, 7, 14]; + const color = (i: number) => { + const lowerBound = lowerBounds[i]; + if (lowerBound >= rotDays) return 'var(--warn)'; + if (lowerBound >= warnDays) return 'color-mix(in oklab, var(--warn) 55%, transparent)'; + return 'var(--ink-3)'; + }; return (
diff --git a/frontend-v2/src/views/stats/StarvationCard.tsx b/frontend-v2/src/views/stats/StarvationCard.tsx index ec129785..b4ccd1a7 100644 --- a/frontend-v2/src/views/stats/StarvationCard.tsx +++ b/frontend-v2/src/views/stats/StarvationCard.tsx @@ -43,8 +43,7 @@ export function StarvationCard({ lead={} trail={ - {row.count} {row.count === 1 ? 'PR' : 'PRs'} · worst{' '} - {row.worstDays}d + {row.count} {row.count === 1 ? 'PR' : 'PRs'} · worst {row.worstDays}d } /> diff --git a/frontend-v2/src/views/stats/TimeInReviewCard.tsx b/frontend-v2/src/views/stats/TimeInReviewCard.tsx index a5b18051..605c1c5f 100644 --- a/frontend-v2/src/views/stats/TimeInReviewCard.tsx +++ b/frontend-v2/src/views/stats/TimeInReviewCard.tsx @@ -39,7 +39,7 @@ export function TimeInReviewCard({ weeks }: { weeks: TimeInReviewWeek[] }) { }, { label: 'time to merge (avg)', - color: 'var(--violet)', + color: 'var(--slate)', values: weeks.map(w => w.mergeAvgHours), }, ]} diff --git a/frontend-v2/src/views/stats/parts.tsx b/frontend-v2/src/views/stats/parts.tsx index d6e309a9..0fef0b82 100644 --- a/frontend-v2/src/views/stats/parts.tsx +++ b/frontend-v2/src/views/stats/parts.tsx @@ -3,13 +3,18 @@ import { Avatar } from '../../components/bits'; import type { Weight } from '../../model/status'; import type { DayCount } from '../../model/stats'; -/** the light→heavy color read every weight surface shares */ +/** + * The light→heavy color read every weight surface shares. Weight/effort is + * always neutral ink — never a hue — so heaviness reads as opacity of one + * token instead of borrowing colors that mean something else (--ok, --warn, + * --bad) on the board. + */ export const WEIGHT_RAMP: Record = { - XS: 'var(--ok)', - S: 'var(--ok)', - M: 'var(--ink-3)', - L: 'var(--warn)', - XL: 'var(--bad)', + XS: 'color-mix(in oklab, var(--ink-3) 35%, transparent)', + S: 'color-mix(in oklab, var(--ink-3) 50%, transparent)', + M: 'color-mix(in oklab, var(--ink-3) 65%, transparent)', + L: 'color-mix(in oklab, var(--ink-3) 82%, transparent)', + XL: 'var(--ink-3)', }; /** From 5611fd9f106bb7c0f8a12ad4830165b680b972b2 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 22:41:06 -0700 Subject: [PATCH 234/381] Retract Classic's stale "new concepts stay out" charter The docstring still promised no v2 concepts while the columns have rendered word sub-headers since the badge-less redesign (added at the owner's explicit request). It now names that one sanctioned exception and explains the deliberate axis split the review flagged as a bug: a column names a v1 predicate, the words name the viewer's verb, so "AWAITING CR" inside QA is v1's parallel-QA behavior made legible, not a grouping error. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/views/Classic.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend-v2/src/views/Classic.tsx b/frontend-v2/src/views/Classic.tsx index c9ba232e..68fc3f2d 100644 --- a/frontend-v2/src/views/Classic.tsx +++ b/frontend-v2/src/views/Classic.tsx @@ -13,8 +13,13 @@ import type { RowOptions } from '../components/Row'; * predicates, the same sorts, straight from frontend/src/pulldasher/index.tsx * and pulldasher/sort.ts. A pull can appear in several columns at once and * empty columns stay visible — that spatial constancy is the muscle memory - * this lens exists to preserve. New concepts (lanes, one-status-per-pull, - * folds) stay out of here on purpose. + * this lens exists to preserve. Lanes and folds stay out on purpose; the ONE + * v2 concept allowed in is the word sub-headers inside each column (the + * owner asked for them here explicitly when badges died board-wide). Note + * the axes deliberately differ: a column names a v1 predicate, the words + * name the viewer's verb — so "AWAITING CR" can appear inside QA (v1's QA + * column never required CR-done; QA runs in parallel). That overlap is v1 + * behavior made legible, not a grouping bug. */ // v1 predicate ports. Where v1 read raw wire fields (dev_block[0] with no From 26ad0389ab5d1afc6174a053080ec66206d56206 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 21 Jul 2026 22:41:25 -0700 Subject: [PATCH 235/381] Fix the mouse-only traps: weight popover, actions, filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three interaction bugs from the review's touch/keyboard pass. The weight meter's trigger had an explicit onClick that overwrote the popover's own toggle, so the panel (the weight word, the +/− lines, "size estimated") could never open by tap or Enter while aria announced a dialog — the trigger now keeps the shared pin contract and the filter action moved inside the panel as a real button. The row-actions cluster/kebab switch keyed on viewport width alone, stranding copy-branch/snooze/refresh/claim on wide touch devices (iPad landscape, Surface) whose hover can never reveal the cluster — it now keys on (hover:hover) and width together. And structured filter-box tokens (weight:xs, is:blocked, repo:…) now render one clearable chip each beside the dropdown pills: a typed token ANDed against a dropdown could silently empty the board with only one of the two culprits visible. Rider: RowOptions also gains maxAgeDays (the board's longest-open pull), threaded here for the next commit's relative age baseline. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 11 ++++ frontend-v2/src/components/Row.tsx | 59 +++++++++++-------- .../src/components/filters/FilterChips.tsx | 24 ++++++++ 3 files changed, 69 insertions(+), 25 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 21ecf2a8..6621ffc5 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -677,6 +677,13 @@ export function App() { } return m; }, [pulls, pools]); + // the age baseline is RELATIVE: the board's longest-open pull sets the + // full track, everything else is a fraction of it — "how long has this + // waited, relative to what waiting looks like here" + const maxAgeDays = useMemo( + () => pulls.reduce((m, p) => Math.max(m, p.ageDays), 1), + [pulls] + ); // stable identity so memo(Row) can skip untouched rows on socket bursts const rowOpts: RowOptions = useMemo( () => ({ @@ -685,6 +692,7 @@ export function App() { acked, onPerson, onWeightToggle, + maxAgeDays, ageWarnDays: settings.ageWarnDays, ageRotDays: settings.ageRotDays, compact: settings.density === 'compact', @@ -700,6 +708,7 @@ export function App() { acked, onPerson, onWeightToggle, + maxAgeDays, settings.ageWarnDays, settings.ageRotDays, settings.density, @@ -934,6 +943,8 @@ export function App() { /> >; onPerson?: (login: string) => void; - /** age-color thresholds from user settings (fall back to the model's) */ + /** age thresholds from user settings (fall back to the model's) */ ageWarnDays?: number; ageRotDays?: number; + /** the board's longest-open pull, in days — the age baseline's full + * track; every row's line is a fraction of the oldest */ + maxAgeDays?: number; /** toggle a weight bucket ('xs'..'xl' or 'unknown') in the session Weight * filter — the row-initiated twin of WeightFilter's own checkboxes */ onWeightToggle?: (w: string) => void; @@ -359,7 +363,7 @@ function RowActions({ )}
); } diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index ef92ab61..a5dcb92d 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -796,6 +796,14 @@ export function AgeStamp({ last activity {ago(updatedAt)} ago + {/* the hairline can't take a hover itself (1px, aria-hidden), so its + explanation lives here, on the numeral that caps it — only when + the line is actually drawn for this row */} + {!quiet && ageDays >= warnDays && ( + + the grey line under this row is its age, relative to the board’s oldest open pull + + )} ); } diff --git a/frontend-v2/src/components/filters/FilterChips.tsx b/frontend-v2/src/components/filters/FilterChips.tsx index 7856ef84..349d7ef0 100644 --- a/frontend-v2/src/components/filters/FilterChips.tsx +++ b/frontend-v2/src/components/filters/FilterChips.tsx @@ -158,7 +158,7 @@ export function FilterChips({ }, }); if (cryoRevealedAlone) - pills.push({ key: 'cryo', label: 'cryo shown', onClear: () => toggleReveal(CRYO_KEY) }); + pills.push({ key: 'cryo', label: 'parked PRs shown', onClear: () => toggleReveal(CRYO_KEY) }); // what "Clear" would change — the transient filters, not your durable // mutes and stars (those are your board; unmute/unstar them in the list) diff --git a/frontend-v2/src/components/filters/WeightFilter.tsx b/frontend-v2/src/components/filters/WeightFilter.tsx index e29a3835..ed58f8ff 100644 --- a/frontend-v2/src/components/filters/WeightFilter.tsx +++ b/frontend-v2/src/components/filters/WeightFilter.tsx @@ -6,11 +6,11 @@ import { FilterRow, OnlyButton } from './shared'; /** Weight filter option order: lightest to heaviest, then the unknown-size * catch-all — mirrors the WeightMeter's own light-to-heavy read. */ const WEIGHT_OPTIONS: { key: string; label: string }[] = [ - { key: 'xs', label: 'XS' }, - { key: 's', label: 'S' }, - { key: 'm', label: 'M' }, - { key: 'l', label: 'L' }, - { key: 'xl', label: 'XL' }, + { key: 'xs', label: 'XS · very light' }, + { key: 's', label: 'S · light' }, + { key: 'm', label: 'M · medium' }, + { key: 'l', label: 'L · heavy' }, + { key: 'xl', label: 'XL · very heavy' }, { key: 'unknown', label: 'Size unknown' }, ]; diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index ad906774..99987b86 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -633,7 +633,7 @@ export const CHEER_CATALOG: { kind: 'your-turn', group: 'nudge', label: 'Your turn to review', - hint: 'You’re the best-matched reviewer for a starved, unclaimed PR.', + hint: 'You’re the best-matched reviewer for a long-waiting, unclaimed PR.', }, { kind: 're-stamp-owed', diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx index 5feb3288..31b63e37 100644 --- a/frontend-v2/src/views/People.tsx +++ b/frontend-v2/src/views/People.tsx @@ -3,7 +3,7 @@ import { STATUS_ORDER, type DerivedPull } from '../model/status'; import { useSettings } from '../settings'; import type { Team } from '../types'; import { Avatar, EmptyState } from '../components/bits'; -import { Fold, FoldRows, Lane, RestGroup } from '../components/Lane'; +import { Fold, FoldRows, Lane, RestGroup, SubDoor } from '../components/Lane'; import type { RowOptions } from '../components/Row'; import { crSort } from '../model/sort'; @@ -60,7 +60,7 @@ export function People({ const selectedTeam = team && teams.some(t => t.team === team) ? team : null; const selectedPerson = selectedTeam ? null : (person ?? defaultPerson); if (!selectedPerson && !selectedTeam) { - return ; + return ; } const members = selectedTeam @@ -198,7 +198,7 @@ export function People({ ? 'nothing open right now' : `${theirs.length} open ${theirs.length === 1 ? 'PR' : 'PRs'}`} {scopeHides > 0 && ( - · scope hides {scopeHides} more + · filters hide {scopeHides} more )} @@ -220,7 +220,21 @@ export function People({
- + +

+ Lightest first, so a short gap fits a review. A pull one stamp from done + jumps up (your stamp finishes it), and waiting adds credit as a pull ages. + PRs still being actively pushed to sink, never hide. +

+ + } + pulls={reviewable} + cap={8} + opts={opts} + /> {(rest.length > 0 || owed.length > 0 || mine.length > 0) && ( {scopeHides > 0 && ( -
scope hides {scopeHides} more
+
filters hide {scopeHides} more
)} {reviewable.length === 0 && stamped.length === 0 && rest.length === 0 && ( @@ -140,19 +140,40 @@ export function Team({ {codeRegions.length > 0 && regionMatches.length > 0 && ( +

+ A PR lands here when its title, description, labels, branch, or repo + contains one of your regions. Plain text, case-insensitive, no regex. +

+ + } pulls={regionMatches} cap={8} opts={opts} /> )} - + +

+ Lightest first, so a short gap fits a review. A pull one stamp from done + jumps up (your stamp finishes it), and waiting adds credit as a pull ages. + PRs still being actively pushed to sink, never hide. +

+ + } + pulls={reviewable} + cap={9} + opts={opts} + /> {(stamped.length > 0 || rest.length > 0) && ( From 0747db1a9ec5ccf438b31aa3880e8037fcc9ece9 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 08:54:54 -0700 Subject: [PATCH 248/381] Drop em dashes from every string the user reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The house voice bans the em dash as prose punctuation, and an audit found it in roughly forty rendered strings: lane subs, fold hints, settings hints, legend definitions, row-note sentences, toast bodies, tooltips. Each one is now a comma, period, semicolon, or colon, chosen by what the sentence actually needed; the "·" separators and the "—" no-data glyph in the CI health table stay, since those are symbols, not prose. Code comments keep their dashes; the reader never sees comments. Along the way the sample toasts in toasts.tsx were re-synced to quote the live catalog strings exactly, and a handful of leftover internal words in stats copy went plain: "starving for CR" is now "waiting long for CR", "wire size" is "diff size", and the last "in this scope" empty states now say "match your filters". Tests that assert the exact strings were updated in the same change. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Legend.tsx | 31 +++++++------ frontend-v2/src/components/RegionHint.tsx | 2 +- frontend-v2/src/components/Row.tsx | 20 ++++----- frontend-v2/src/components/Settings.tsx | 6 +-- frontend-v2/src/components/StatePopover.tsx | 12 ++--- .../src/components/filters/FilterChips.tsx | 2 +- .../src/components/filters/PeopleFilter.tsx | 2 +- frontend-v2/src/model/actions.test.ts | 12 ++--- frontend-v2/src/model/actions.ts | 12 ++--- frontend-v2/src/model/cheers.test.ts | 4 +- frontend-v2/src/model/cheers.ts | 22 +++++----- frontend-v2/src/toasts.tsx | 10 ++--- frontend-v2/src/views/Ci.tsx | 44 +++++++++---------- frontend-v2/src/views/Classic.tsx | 2 +- frontend-v2/src/views/Review.tsx | 18 ++++---- frontend-v2/src/views/Stats.tsx | 5 ++- frontend-v2/src/views/Team.tsx | 8 ++-- frontend-v2/src/views/stats/DebtCard.tsx | 2 +- frontend-v2/src/views/stats/EffortMixCard.tsx | 2 +- frontend-v2/src/views/stats/MergeSizeCard.tsx | 2 +- .../src/views/stats/StarvationCard.tsx | 2 +- 21 files changed, 111 insertions(+), 109 deletions(-) diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index ed0dc1cf..777ceb6e 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -119,7 +119,7 @@ export function Legend() { /> } - def={`age — a grey hairline along a row's bottom edge appears once it's ${s.ageWarnDays}+ days without full review; its length and depth are relative to the board's longest-open pull, so the oldest runs full width in full grey. The quiet day count at the row's right edge caps it and just gets bolder — hover it for both clocks`} + def={`age: a grey hairline along a row's bottom edge appears once it's ${s.ageWarnDays}+ days without full review; its length and depth are relative to the board's longest-open pull, so the oldest runs full width in full grey. The quiet day count at the row's right edge caps it and just gets bolder; hover it for both clocks`} /> } - def="review effort — the strip under the CR/QA marks fills with the review’s weight, doubling per class: a sliver = a quick pickup, full = very heavy. Hover for the exact +/− lines, click to filter" + def="review effort: the strip under the CR/QA marks fills with the review’s weight, doubling per class: a sliver = a quick pickup, full = very heavy. Hover for the exact +/− lines, click to filter" />
@@ -147,9 +147,8 @@ export function Legend() {

If the PR carries one of the org’s size labels, that wins: it’s deterministic, per-file-weighted, and versioned with the labeller. With no label it’s a guess - from the diff — under 50 lines changed reads XS, under 150 S, under 600 M, - under 1500 L, and 1500+ XL, bumped up a class when the PR spans more than 15 - files. + from the diff: under 50 lines changed reads XS, under 150 S, under 600 M, under + 1500 L, and 1500+ XL, bumped up a class when the PR spans more than 15 files.

It’s only a prior, never a verdict: a tiny diff can hide a subtle change and a @@ -166,11 +165,11 @@ export function Legend() { } - def="CI wears the same marks as CR and QA — the machine is a reviewer too. A red ✗ disc, drawn a size larger than every other mark, means failing checks (the count beside it says how many); the gray-blue ring means still running. Passing shows nothing at rest — no news is good news; hover a row for its quiet green check and the per-check list" + def="CI wears the same marks as CR and QA. The machine is a reviewer too. A red ✗ disc, drawn a size larger than every other mark, means failing checks (the count beside it says how many); the gray-blue ring means still running. Passing shows nothing at rest. No news is good news; hover a row for its quiet green check and the per-check list" /> } - def="lines added and removed — neutral on purpose: a line count is a routine fact, not a verdict" + def="lines added and removed, neutral on purpose: a line count is a routine fact, not a verdict" /> ◆region } - def="matches a code region you set in Settings — these gather in the “In your code regions” section on Review and Team" + def="matches a code region you set in Settings; these gather in the “In your code regions” section on Review and Team" />

@@ -198,7 +197,7 @@ export function Legend() {

- Code regions are free text you set in Settings — the areas you own or follow, + Code regions are free text you set in Settings, the areas you own or follow, like “Growthbook” or “Shopify”. They’re a plain text match, not a regex.

@@ -222,7 +221,7 @@ export function Legend() { ✋ } - def="claim a review so teammates know you’re on it — the hand stays lit on your row, and claimed PRs collect in “You’re reviewing”. It nudges you when it goes stale and clears itself when it expires, both set in Settings (defaults: 2h and 4h)" + def="claim a review so teammates know you’re on it; the hand stays lit on your row, and claimed PRs collect in “You’re reviewing”. It nudges you when it goes stale and clears itself when it expires, both set in Settings (defaults: 2h and 4h)" /> X is reading it} @@ -255,7 +254,7 @@ export function Legend() {

your turn} - def="a starved review nobody’s on gets pointed at the best-matched person so it doesn’t sit forever — a toast asks them to claim it, and anyone can still take it" + def="a starved review nobody’s on gets pointed at the best-matched person so it doesn’t sit forever; a toast asks them to claim it, and anyone can still take it" />
@@ -270,14 +269,14 @@ export function Legend() { board points it at one reviewer so it stops falling through the cracks. The candidates are everyone who’s CR’d that repo before (minus the author and anyone who already stamped this one), so they all know the code; the pick then - favors whoever the author has reviewed before — a good turn owed back, the same + favors whoever the author has reviewed before, a good turn owed back, the same signal “Deal me one” leans on.

If you’re the pick, a toast asks you to claim it right there (which also adds you as a GitHub reviewer). Every client lands on the same name with no coordination, and ties spread across PRs so it isn’t always one person. It’s a - nudge, not a lock — anyone can take it, and an explicit GitHub review request + nudge, not a lock; anyone can take it, and an explicit GitHub review request overrides the guess entirely.

@@ -299,7 +298,7 @@ export function Legend() { waiting on CR · 2 } - def="gray group header: why those cards wait. Hover a card’s repo #number for the full state — status, names, dates, CI, feedback" + def="gray group header: why those cards wait. Hover a card’s repo #number for the full state: status, names, dates, CI, feedback" /> @@ -324,7 +323,7 @@ export function Legend() { weight:xs,s} - def="review-effort class(es), comma list ORs — click a row’s weight meter to toggle it" + def="review-effort class(es), comma list ORs; click a row’s weight meter to toggle it" /> has:action} @@ -340,7 +339,7 @@ export function Legend() { /> diff --git a/frontend-v2/src/components/RegionHint.tsx b/frontend-v2/src/components/RegionHint.tsx index 1b5123aa..a58f7a64 100644 --- a/frontend-v2/src/components/RegionHint.tsx +++ b/frontend-v2/src/components/RegionHint.tsx @@ -28,7 +28,7 @@ export function RegionHint() { Review a particular area? Add code regions in Settings. - A region is plain text matched against a PR's title, labels, branch, and repo; + A region is plain text matched against a PR’s title, labels, branch, and repo; matches gather in their own section above the queue. diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index c3471169..1930c52d 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -428,7 +428,7 @@ export function Settings({ {perm === 'denied' && ( - Blocked in your browser settings — allow notifications for this + Blocked in your browser settings; allow notifications for this site to turn them on. )} diff --git a/frontend-v2/src/components/StatePopover.tsx b/frontend-v2/src/components/StatePopover.tsx index 2dc5af22..31037703 100644 --- a/frontend-v2/src/components/StatePopover.tsx +++ b/frontend-v2/src/components/StatePopover.tsx @@ -68,9 +68,9 @@ function feedbackSources(p: DerivedPull): FeedbackSource[] { * four never set one, so the popover restates the action in plain words * instead of leaving Section 1 blank. */ const ACTION_EXPLANATION: Record = { - 'Finish the draft': 'still being drafted — not ready for review yet.', - 'Merge it': 'fully signed off and green — go ahead and merge.', - 'Finish QA': "you're already testing it — finish up and stamp.", + 'Finish the draft': 'still being drafted, not ready for review yet.', + 'Merge it': 'fully signed off and green, go ahead and merge.', + 'Finish QA': "you're already testing it, finish up and stamp.", 'Re-QA': 'a push invalidated your QA stamp.', }; @@ -101,7 +101,9 @@ function StateSection({

{STATUS_LABEL[pull.status]}

{stateExplanation(pull, note)}

- {note.action &&

your next step: {note.action}

} + {note.action && ( +

your next step: {note.action}

+ )}
); } @@ -241,7 +243,7 @@ function StatePopoverBody({ door as everything else, never inline on the row */} {whyHere && (

- why it’s up next — + why it’s up next: {whyHere}

)} diff --git a/frontend-v2/src/components/filters/FilterChips.tsx b/frontend-v2/src/components/filters/FilterChips.tsx index 349d7ef0..f5c20df8 100644 --- a/frontend-v2/src/components/filters/FilterChips.tsx +++ b/frontend-v2/src/components/filters/FilterChips.tsx @@ -205,7 +205,7 @@ export function FilterChips({ ) : ( <> setOpen(false)}>Done} /> @@ -193,7 +193,7 @@ function DealStrip({ queue, opts }: { queue: DerivedPull[]; opts: RowOptions }) /> ) : (
- Nothing left to deal — you’ve claimed or passed everything in the queue. + Nothing left to deal. You’ve claimed or passed everything in the queue.
)} @@ -484,10 +484,10 @@ export function Review({ // ranking this lane doesn't use. const whyQaNext = (p: DerivedPull) => p.qaingLogin - ? `${p.qaingLogin} is already testing it — it sinks below unclaimed QA` + ? `${p.qaingLogin} is already testing it; it sinks below unclaimed QA` : p.sizeKnown && (p.weight === 'XS' || p.weight === 'S') - ? `Nobody's testing it yet — a light one (${p.weight})` - : `Nobody's testing it yet — waiting ${Math.max(1, Math.round(p.ageDays))}d`; + ? `Nobody's testing it yet, a light one (${p.weight})` + : `Nobody's testing it yet, waiting ${Math.max(1, Math.round(p.ageDays))}d`; const boardIsQuiet = !yourMove.length && @@ -537,7 +537,7 @@ export function Review({ text="every PR whose next step is yours, most urgent first" >

- If it's in this lane, nothing happens until you act: + If it’s in this lane, nothing happens until you act:

re-stamps a push owes, feedback waiting on your answer, your own merge @@ -572,7 +572,7 @@ export function Review({ >

Nothing here needs you right now:

- your own PRs waiting on a review, QA, or CI, plus PRs you've already stamped + your own PRs waiting on a review, QA, or CI, plus PRs you’ve already stamped that are still waiting on another reviewer.

Grouped by what each one waits on.

@@ -706,7 +706,7 @@ export function Review({ dot={STATUS_DOT.dev_block} count={devBlocked.length} label="dev blocked" - hint="paused by the author — nothing to review yet" + hint="paused by the author, nothing to review yet" id="review:dev-blocked" > diff --git a/frontend-v2/src/views/Stats.tsx b/frontend-v2/src/views/Stats.tsx index 12ceee4d..9b687338 100644 --- a/frontend-v2/src/views/Stats.tsx +++ b/frontend-v2/src/views/Stats.tsx @@ -123,7 +123,10 @@ export function Stats({ if (!pulls.length && !closed.length) { return ( - + ); } diff --git a/frontend-v2/src/views/Team.tsx b/frontend-v2/src/views/Team.tsx index bf502e94..b167f070 100644 --- a/frontend-v2/src/views/Team.tsx +++ b/frontend-v2/src/views/Team.tsx @@ -71,7 +71,7 @@ export function Team({

Build your team

- Pick the people whose work you review — the Team view and the “Your team” filter + Pick the people whose work you review, the Team view and the “Your team” filter follow this list.

@@ -158,9 +158,9 @@ export function Team({ sub={

- Lightest first, so a short gap fits a review. A pull one stamp from done - jumps up (your stamp finishes it), and waiting adds credit as a pull ages. - PRs still being actively pushed to sink, never hide. + Lightest first, so a short gap fits a review. A pull one stamp from done jumps + up (your stamp finishes it), and waiting adds credit as a pull ages. PRs still + being actively pushed to sink, never hide.

} diff --git a/frontend-v2/src/views/stats/DebtCard.tsx b/frontend-v2/src/views/stats/DebtCard.tsx index 0a22ea4d..10c131cc 100644 --- a/frontend-v2/src/views/stats/DebtCard.tsx +++ b/frontend-v2/src/views/stats/DebtCard.tsx @@ -41,7 +41,7 @@ export function DebtCard({ debt }: { debt: ReviewDebt }) { {cell( debt.restamps, 're-stamps owed', - 'stamps a push invalidated — the reviewer already knows the code', + 'stamps a push invalidated; the reviewer already knows the code', true )} {cell(debt.unclaimedQa, 'QA unclaimed', 'needs-QA PRs nobody has picked up')} diff --git a/frontend-v2/src/views/stats/EffortMixCard.tsx b/frontend-v2/src/views/stats/EffortMixCard.tsx index 2602daf8..2ad46f22 100644 --- a/frontend-v2/src/views/stats/EffortMixCard.tsx +++ b/frontend-v2/src/views/stats/EffortMixCard.tsx @@ -42,7 +42,7 @@ export function EffortMixCard({ mix }: { mix: EffortMix }) {
{mix.estimated > 0 && (
- {mix.estimated} without a wire size (weight estimated) + {mix.estimated} without a diff size (weight estimated)
)} diff --git a/frontend-v2/src/views/stats/MergeSizeCard.tsx b/frontend-v2/src/views/stats/MergeSizeCard.tsx index ee314600..dcb46bf1 100644 --- a/frontend-v2/src/views/stats/MergeSizeCard.tsx +++ b/frontend-v2/src/views/stats/MergeSizeCard.tsx @@ -22,7 +22,7 @@ export function MergeSizeCard({ No merged PRs with size data in this window. {merged > 0 && (
- {merged} merged, but no diff sizes on the wire + {merged} merged, but no diff sizes from GitHub
)}
diff --git a/frontend-v2/src/views/stats/StarvationCard.tsx b/frontend-v2/src/views/stats/StarvationCard.tsx index b4ccd1a7..ed41620f 100644 --- a/frontend-v2/src/views/stats/StarvationCard.tsx +++ b/frontend-v2/src/views/stats/StarvationCard.tsx @@ -25,7 +25,7 @@ export function StarvationCard({ sub="by author · total open-days their PRs sit unreviewed" > {shown.length === 0 ? ( -
No PRs are starving for CR right now.
+
Nothing has been waiting long for CR.
) : (
{shown.map(row => { From e579288ee642f27ae5555b3b445c640569da4230 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 08:56:58 -0700 Subject: [PATCH 249/381] Retire Deal me one; the queue is the deal now The feature's own history made it redundant. It existed because picking a review used to take judgment the board didn't show; then the review queue adopted the deal's exact ranking so the button and the lane could never disagree; at that point "Deal me one" was a button that showed you the first row of the list directly below it. Claim already lives on every row's hover cluster, passing is just reading down the list, and the why-this-one reason is in each card's state popover. The owner called it: drop it. Removed: the top-of-page strip and dealt banner (DealStrip/DealtCard), the claim-then-scroll landing effect, the row-flash machinery only the deal used (markDealtFlash), and the queue popover's deal sentence. Kept: model/deal.ts whole. dealRank still ranks the queue, and dealFrom still picks the Start-here toast's best pull; only the UI around them retired. DESIGN.md now records the full arc as doctrine: once a control and its list share one ordering, the control is redundant, because a ranked lane IS the recommendation. Co-Authored-By: Claude Opus 4.8 --- DESIGN.md | 73 ++++++---- frontend-v2/src/components/Card.tsx | 2 +- frontend-v2/src/components/Lane.tsx | 5 +- frontend-v2/src/components/Legend.tsx | 2 +- frontend-v2/src/components/Row.tsx | 22 +-- frontend-v2/src/format.ts | 2 +- frontend-v2/src/store.ts | 2 +- frontend-v2/src/views/Review.tsx | 190 ++------------------------ 8 files changed, 65 insertions(+), 233 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index b9eb4b70..f6ee0329 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -66,32 +66,28 @@ only remaining fact is when it landed) · avatar · title · repo# · age. No rail — nothing is left to act on. Every lens that shows closed pulls renders this component; never a second hand-rolled closed card. -**Every card that shows a PR is a board row** — the Deal-me-one result, fold -contents, popover previews. Same element order, same marks, same doors. A -context may *add* (a "why this one" footnote, claim buttons) but never -reorder or restate — inline status text on a card is always a regression. - -**A button that acts on a list acts on the list the user sees.** The review -queue is ranked by the same score Deal-me-one deals from, so the button -always takes the top visible card. Two orderings for one list — one shown, -one hidden inside a button — is a least-surprise bug even when both are -individually sensible; if a pick needs extra signals, put them in the -lane's ranking and explain them behind the sub-line. A control that sits -away from its list (the deal strip at the top of the page) names the list -in its own label — "takes the top card of your review queue" — so the -contract survives the distance. And when the action commits somewhere (a -claim re-buckets the row into "Your move"), take the user there: scroll to -the row's new home and flash it, so the commitment visibly lands instead -of evaporating into a lane they aren't looking at. +**Every card that shows a PR is a board row** — fold contents, popover +previews, any future one-off surface. Same element order, same marks, same +doors. A context may *add* (a "why this one" footnote, claim buttons) but +never reorder or restate — inline status text on a card is always a +regression. + +**A button that acts on a list acts on the list the user sees — and once +they fully agree, the button is redundant.** The retired "Deal me one" +feature walked the whole arc: first its hidden ranking diverged from the +queue's (a least-surprise bug), so the queue adopted the deal's exact +score; then the button could only ever hand you the top visible card of +the lane directly under it, so it was removed entirely. The rule that +remains: a ranked lane IS the recommendation — put pick logic in the +lane's ordering and explain it behind the sub-line, never inside a control +with its own private order. **Work-in-progress lives inline, not in a popover.** A popover is for glancing (state detail, signatures, rankings) and rightly dies on any -outside click. A dealt card is a commitment in progress — it renders as an -inline banner section in the board's own flow, full-width so the row keeps -the board's rail geometry, dismissed only by an explicit Done or Escape. -The deal popover shipped first and had three failure modes at once: buried -mid-page on the queue lane's header, a row squeezed to 320px, and -stray-click dismissal mid-triage. +outside click. Anything the user is mid-way through renders in the board's +own flow, full-width so rows keep the rail geometry, dismissed only +explicitly. (The retired deal feature learned this the hard way: its +popover era dismissed a commitment-in-progress on any stray click.) **Ranked lanes explain themselves in three quiet layers**: the sub-line says the ordering in one plain sentence (and is itself the hover-door to @@ -152,6 +148,37 @@ conceptually perfect and rendered as a blob). band labels share the exported constant, never a hand-rolled copy) > `Fold` summaries (disclosure rows, not titles). +## Copy rules (static text is part of the visual system) + +- **Say the thing, don't be clever.** Lane names state their contents + plainly: "Waiting on you" / "Waiting on others", never a metaphor the + reader has to decode ("Your move" tested badly — some developers didn't + parse the chess reference, and clever-compressed titles read as + AI-written). +- **One concept, one word, everywhere it appears.** The same underlying + fact must use the same word in the lane title, the group eyebrow, the + State filter, the notification title, and the settings toggle (an owed + re-stamp is "Re-stamp" / "Re-stamp owed" on every surface; it was once + also "Re-review owed"). Before adding copy, grep for the concept's + existing word. +- **Team vocabulary is native, not jargon.** CR, QA, stamp, re-stamp, + rebase come from this team's own workflow (v1 heritage, `cr_req` in the + DB) and stay. What goes: internal engineering words the reader never + chose — "scope" (say "filters"), "starved" (say how long it waited), + "lens" (say "tab"), "wire" (say where the data comes from), codenames + without their plain gloss ("cryo" → "parked"). +- **No em dashes in rendered copy.** Comma, period, semicolon, colon, or + the house "·" separator. (Comments may keep them; the reader never sees + comments.) +- **Every curated lane's sub-line is a door** (`SubDoor` in Lane.tsx): the + visible sentence states the ordering, hovering it opens what lands in + the lane and how it's ranked. Every group eyebrow glosses itself the + same way (WORD_GLOSS). A control that sits away from the thing it acts + on names that thing in its own label, so the connection survives the + distance. The test for any new mark, header, or lane: a + developer who has never opened the legend can decode it from the screen + alone. The legend documents; it never teaches. + ## Verification discipline (how design changes get accepted here) - Judge at **real render px** and zoomed, in-situ beside real neighbors, in diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index f5759ec0..d10e8c6c 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -66,7 +66,7 @@ export function CardShell({ number: number; title: string; onOpen?: () => void; - /** stable DOM id (format.ts's rowDomId) — lets "Deal me one" scroll to and + /** stable DOM id (format.ts's rowDomId) — lets toasts scroll to and * flash the row it just claimed. */ id?: string; className?: string; diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index e5707a69..1d3917bc 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -79,8 +79,7 @@ export function Rows({ children }: { children: ReactNode }) { /** The shared lane/group header: title, optional subtitle, optional * right-aligned count and/or an extra control. Exported so one-off sections - * (Review's dealt-to-you banner) use the one header system instead of - * hand-rolling a lookalike. */ + * use the one header system instead of hand-rolling a lookalike. */ export function GroupHeader({ title, sub, @@ -155,7 +154,7 @@ export function Lane({ opts: RowOptions; /** extra rows rendered inside the container, before the more-line */ children?: ReactNode; - /** an extra control right-aligned in the header, e.g. Review's "Deal me one" */ + /** an extra control right-aligned in the header */ headerExtra?: ReactNode; }) { if (!pulls.length && !children) return null; diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 777ceb6e..c6eba4cb 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -270,7 +270,7 @@ export function Legend() { candidates are everyone who’s CR’d that repo before (minus the author and anyone who already stamped this one), so they all know the code; the pick then favors whoever the author has reviewed before, a good turn owed back, the same - signal “Deal me one” leans on. + signal the review queue’s ranking leans on.

If you’re the pick, a toast asks you to claim it right there (which also adds diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index c465cb9e..720a3dda 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -98,26 +98,6 @@ function flashOnce(key: string, fresh: boolean): boolean { return true; } -/** - * The same one-shot `.row-fresh` highlight a brand-new/updated row gets, - * requested on demand for a row that ISN'T fresh — Review's "Deal me one" - * flashing the pull it just claimed. Recorded in a Set exactly like flashOnce - * above (and consumed the same one-shot way below) rather than mutated - * directly on the DOM: a claim always changes opts.claims's reference, which - * forces this memoized row to re-render on its own shortly after (see Row's - * memo comparator) — direct className mutation would just get clobbered by - * that re-render, since Row doesn't otherwise know to keep the class. - */ -const dealtFlash = new Set(); -export function markDealtFlash(key: string): void { - dealtFlash.add(key); -} -function consumeDealtFlash(key: string): boolean { - if (!dealtFlash.has(key)) return false; - dealtFlash.delete(key); - return true; -} - interface Flag { key: string; /** 'warn' = act on it (amber); 'note' = a neutral fact (muted) */ @@ -760,7 +740,7 @@ function RowImpl({ id={rowDomId(d)} compact={opts.compact} depth={depth} - className={`${flashOnce(key, !!fresh) || consumeDealtFlash(key) ? 'row-fresh' : ''} transition-[background-color] duration-150 ease-out motion-reduce:transition-none`} + className={`${flashOnce(key, !!fresh) ? 'row-fresh' : ''} transition-[background-color] duration-150 ease-out motion-reduce:transition-none`} avatarBadge={ starredAuthor && ( ): void { diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 9d99db04..bd530dae 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -1,6 +1,5 @@ -import { useEffect, useState } from 'react'; import { type DerivedPull, qaDone, type Status, weightRank } from '../model/status'; -import { pullKey, rowDomId } from '../format'; +import { pullKey } from '../format'; import { crSort, starFirst } from '../model/sort'; import { matchedRegions, matchesRegion } from '../model/regions'; import { @@ -13,196 +12,25 @@ import { } from '../model/actions'; import { reviewRequestedFrom } from '../model/reviewers'; import { startHereReason } from '../model/cheers'; -import { dealFrom, dealRank } from '../model/deal'; +import { dealRank } from '../model/deal'; import { useSettings } from '../settings'; -import { claimFor, claimReview, isFresh, usePulldasher } from '../store'; +import { claimFor, isFresh } from '../store'; import type { PullData } from '../types'; -import { EmptyState, QuietButton, STATUS_DOT, STATUS_LABEL } from '../components/bits'; +import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; import { Fold, FoldRows, - GroupHeader, Lane, laneShown, RestGroup, - Rows, SubDoor, Truncated, } from '../components/Lane'; import { RegionHint } from '../components/RegionHint'; -import { markDealtFlash, Row, type RowOptions } from '../components/Row'; +import type { RowOptions } from '../components/Row'; import { eyebrowText, WordGroupRows } from '../components/WordGroups'; import { ClosedRow } from '../components/ClosedRow'; -/** - * The dealt pull IS a board row — the real Row component, not a re-drawn - * card, so the banner shows exactly what the queue shows (alarm CI, pips, - * weight strip, age baseline, the repo# state door, flags, hover actions) - * and can never drift from it. Below the row: the "why this one" line as a - * quiet footnote — reasons are context, not state — and the two verbs. - * Claiming is a commitment (it also adds you as a GitHub reviewer), so it's - * an explicit button, not a side effect of dealing. - */ -function DealtCard({ - pull, - opts, - pulls, - onClaim, - onPass, -}: { - pull: DerivedPull; - opts: RowOptions; - pulls: DerivedPull[]; - onClaim: () => void; - onPass: () => void; -}) { - return ( -

- -
- {startHereReason(pull, pulls, opts.me, true)} -
-
- - Claim it - - Pass -
-
- ); -} - -/** - * "Deal me one" as the board's greeting: for a reviewer who'd rather be - * handed the next pull than browse, a quiet full-width strip at the very top - * of the lens — the page opens by answering "what should I pick up next?". - * Clicking it unfolds an inline banner (a lane-shaped section, the one - * header system) holding the dealt pull as a REAL full-width board row. - * It used to be a popover on the queue lane's header, which buried the - * feature mid-page, squeezed the row to 320px (denying it the board's rail - * geometry), and dismissed on any stray click mid-commitment — an inline - * banner has none of those failure modes and keeps the queue visible - * beneath, so you can see the dealt card IS the queue's top card. - * - * "Claim it" takes the pull (and adds you as a GitHub reviewer), then deals - * the next for an uninterrupted run of triage — and once the claim lands in - * the store, scrolls to the row in its new lane ("Waiting on you", right below) - * and flashes it, so the commitment visibly arrives somewhere. "Pass" skips - * without claiming. "Done" or Escape folds the banner back to the strip. - */ -function DealStrip({ queue, opts }: { queue: DerivedPull[]; opts: RowOptions }) { - const { pulls } = usePulldasher(); - const [open, setOpen] = useState(false); - const [dealtKey, setDealtKey] = useState(null); - const [passed, setPassed] = useState>(new Set()); - // the claim we're waiting to see land in the store, so the scroll targets - // the row AFTER it has moved to its new lane, not its old queue position - const [landing, setLanding] = useState<{ key: string; repo: string; number: number } | null>( - null - ); - - const deal = (passedNow: ReadonlySet) => { - const picked = dealFrom(queue, { claims: opts.claims ?? {}, passed: passedNow }); - setDealtKey(picked ? pullKey(picked.data) : null); - }; - - const dealt = dealtKey ? queue.find(p => pullKey(p.data) === dealtKey) : null; - - const claim = () => { - if (!dealtKey || !dealt) return; - // flash the row as its claim badge appears (the store publish re-renders - // it a beat later, which is what paints markDealtFlash's highlight), then - // deal the next straight away for an uninterrupted run of triage - markDealtFlash(dealtKey); - claimReview(dealt.data); - setLanding({ key: dealtKey, repo: dealt.data.repo, number: dealt.data.number }); - const next = new Set(passed); - next.add(dealtKey); - setPassed(next); - deal(next); - }; - - const pass = () => { - if (!dealtKey) return; - const next = new Set(passed); - next.add(dealtKey); - setPassed(next); - deal(next); - }; - - // scroll to the claimed row once the store's claims include it — that's - // the same publish that re-buckets it into "Waiting on you" and paints the - // markDealtFlash highlight, so the scroll lands on its settled position. - // A frame's wait lets the re-render commit first. - useEffect(() => { - if (!landing || opts.claims?.[landing.key]?.login !== opts.me) return; - const target = landing; - setLanding(null); - requestAnimationFrame(() => { - document - .getElementById(rowDomId(target)) - ?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); - }, [landing, opts.claims, opts.me]); - - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpen(false); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [open]); - - // nothing to deal and no session in progress: no strip at all — a button - // that could only say "nothing left" is noise, not an affordance - if (!queue.length && !open) return null; - - return ( -
- {!open ? ( - - ) : ( - <> - setOpen(false)}>Done} - /> - - {dealt ? ( - - ) : ( -
- Nothing left to deal. You’ve claimed or passed everything in the queue. -
- )} -
- - )} -
- ); -} - /** * The home tab. It opens with the one lane the whole app used to lack: every * action that is yours — re-stamps you owe, your own merge buttons, your CI @@ -349,9 +177,9 @@ export function Review({ // regardless of repo (the fairness backstop rides in the ranking now, not // a separate Aging lane — starveScore's uncapped age × size term floats // them to the top numerically instead of positionally), and the day's bot - // bumps sinking to the tail. Ranked by the exact score Deal me one uses, - // so the button always deals the top visible card — the list and the - // button can't disagree. Non-starved work outside your primary repos still + // bumps sinking to the tail. The top of this lane IS the board's best + // next pickup; the retired "Deal me one" button dealt this exact order, + // which is why the button became redundant and was removed. Non-starved work outside your primary repos still // folds into "other repos" below, reachable but not in the way. Region // matches are excluded here too (same Set-filter pattern as botKeys) — they // live in their own lane above, not doubled up in the queue. @@ -526,7 +354,6 @@ export function Review({ return ( <> - {codeRegions.length === 0 && } {yourMove.length > 0 && ( -

“Deal me one” deals the top card that isn’t claimed or passed.

} pulls={queue} From 56af13a9c992aaf9cec0c941f3927466f744020d Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 09:00:37 -0700 Subject: [PATCH 250/381] Rewrite compressed copy so a junior dev parses it first read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's test for all static text: someone in their first week understands it without asking. The failure mode this pass hunts is compression, several ideas folded into a noun phrase that only parses if you already know the system: - "re-stamps a push owes" (the Waiting on you door) is now "PRs you approved that changed after your approval (they need a fresh re-stamp from you)". Three ideas, spelled out in order. - "fell to a push" (word glosses, row notes) is now "new commits landed" / "new commits undid X's QA". A push is the mechanism; the commits changing under an approval are the point. - The queue door drops "longest-and-heaviest waiters first (age × size)" for "oldest and biggest first", and "bot bumps sink to the tail" for "bot PRs (dependency bumps) sink to the bottom". - The Team/People queue door had a garbled sentence ("PRs still being actively pushed to sink, never hide"); it now says what happens: "PRs the author is still actively pushing sink to the bottom." - The Re-stamp gloss teaches the word by walking the sequence: you approved, new commits landed and undid your approval, check and approve again. Tests updated in lockstep with the one row-note change. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/WordGroups.tsx | 8 ++++---- frontend-v2/src/model/actions.test.ts | 2 +- frontend-v2/src/model/actions.ts | 2 +- frontend-v2/src/views/People.tsx | 6 +++--- frontend-v2/src/views/Review.tsx | 18 +++++++++--------- frontend-v2/src/views/Team.tsx | 6 +++--- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/frontend-v2/src/components/WordGroups.tsx b/frontend-v2/src/components/WordGroups.tsx index 9b4c6a89..71dbbff1 100644 --- a/frontend-v2/src/components/WordGroups.tsx +++ b/frontend-v2/src/components/WordGroups.tsx @@ -25,8 +25,8 @@ export const eyebrowText = 'text-[11px] font-semibold tracking-wide uppercase'; const WORD_GLOSS: Record = { // do-words: the next step is yours 'Re-stamp': - 'You approved an earlier version and a push invalidated it. Confirm it still looks good.', - 'Re-QA': 'Your QA stamp fell to a push. Test it again.', + 'You approved this PR, then new commits landed and undid your approval. Check the changes and approve again.', + 'Re-QA': 'You tested this PR, then new commits landed. Test it again.', 'Finish QA': 'You started testing these. Finish and stamp.', 'Finish CR': 'You started reviewing; your stamp isn’t in yet.', 'Re-review': 'You asked for changes and they pushed. Take another look.', @@ -41,10 +41,10 @@ const WORD_GLOSS: Record = { QA: 'Open PRs you could test.', 'Finish draft': 'Your draft. Not up for review until you open it.', // wait-words: why the pull sits - 'waiting on re-CR': 'A reviewer’s stamp fell to a push; waiting on them to confirm it again.', + 'waiting on re-CR': 'A reviewer approved it, then new commits landed. Waiting on them to approve again.', 'waiting on CR': 'Waiting for someone to code review it.', 'with author': 'Changes were requested; the next push is the author’s.', - 'waiting on re-QA': 'A tester’s stamp fell to a push; waiting on them to re-test.', + 'waiting on re-QA': 'Someone tested it, then new commits landed. Waiting on them to test again.', 'waiting on QA': 'Waiting for someone to test it.', 'in QA': 'Someone is testing it right now.', claimed: 'Someone flagged they’re reading it.', diff --git a/frontend-v2/src/model/actions.test.ts b/frontend-v2/src/model/actions.test.ts index b3019715..8d3c5b56 100644 --- a/frontend-v2/src/model/actions.test.ts +++ b/frontend-v2/src/model/actions.test.ts @@ -208,7 +208,7 @@ describe('rowNote — the author matrix', () => { }); expect(note({ status: 'needs_qa', reqaBy: ['frank'] }, me)).toEqual({ action: null, - context: "frank's QA fell to a push", + context: "new commits undid frank's QA", }); }); diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index d9a02039..65c20ae9 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -138,7 +138,7 @@ function authorNote(p: DerivedPull, me: string): RowNote { if (p.status === 'needs_qa') { if (!p.qaingLogin && !p.reqaBy.length) return doOnly('Find a QA-er'); if (p.qaingLogin) return waitOnly(`${who([p.qaingLogin])} is testing it`); - if (p.reqaBy.length) return waitOnly(`${who(p.reqaBy)}'s QA fell to a push`); + if (p.reqaBy.length) return waitOnly(`new commits undid ${who(p.reqaBy)}'s QA`); } if (p.status === 'needs_recr') return waitOnly(`waiting on ${who(p.recrBy)} to re-stamp${pushed}`); diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx index 31b63e37..ab675a6e 100644 --- a/frontend-v2/src/views/People.tsx +++ b/frontend-v2/src/views/People.tsx @@ -225,9 +225,9 @@ export function People({ sub={

- Lightest first, so a short gap fits a review. A pull one stamp from done - jumps up (your stamp finishes it), and waiting adds credit as a pull ages. - PRs still being actively pushed to sink, never hide. + Lightest first, so a short break fits a review. A PR that needs just one + more approval jumps up (yours would finish it), and PRs move up as they + wait. PRs the author is still actively pushing to sink to the bottom.

} diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index bd530dae..30302fa5 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -367,9 +367,10 @@ export function Review({ If it’s in this lane, nothing happens until you act:

- re-stamps a push owes, feedback waiting on your answer, your own merge - buttons, CI fixes and rebases on your PRs, reviews requested of you, and - claims you hold. + PRs you approved that changed after your approval (they need a fresh + re-stamp from you), feedback that needs your reply, your own PRs ready to + merge, fix, or rebase, PRs someone asked you to review, and reviews you + claimed.

Grouped by the action, most urgent action first, oldest first inside a @@ -460,14 +461,13 @@ export function Review({

One score ranks every card:

- pulls open {opts.ageWarnDays ?? 4}+ days without a full CR float to the top, - longest-and-heaviest waiters first (age × size), even outside your primary - repos. + PRs that have waited {opts.ageWarnDays ?? 4}+ days for review jump to the + top, oldest and biggest first, even from repos you don’t usually review.

- Then: repos you’ve stamped before, authors who’ve reviewed yours, and small - quick wins, lightest first. Pulls from people you’ve starred always lead, ahead - of everything above; bot bumps sink to the tail. + After those: PRs in repos you’ve reviewed before, PRs from people who review + your work, and small quick wins, lightest first. PRs from people you starred + always come first; bot PRs (dependency bumps) sink to the bottom.

} diff --git a/frontend-v2/src/views/Team.tsx b/frontend-v2/src/views/Team.tsx index b167f070..5984f34e 100644 --- a/frontend-v2/src/views/Team.tsx +++ b/frontend-v2/src/views/Team.tsx @@ -158,9 +158,9 @@ export function Team({ sub={

- Lightest first, so a short gap fits a review. A pull one stamp from done jumps - up (your stamp finishes it), and waiting adds credit as a pull ages. PRs still - being actively pushed to sink, never hide. + Lightest first, so a short break fits a review. A PR that needs just one + more approval jumps up (yours would finish it), and PRs move up as they + wait. PRs the author is still actively pushing to sink to the bottom.

} From f29ad2025aca45f5f713221cde7bac50f95be3cd Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 10:07:52 -0700 Subject: [PATCH 251/381] Reformat three server files with biome (no logic change) git-manager.js, pull.js, and githubHooks.js carried a quote and indentation style different from the rest of the repo (double quotes, 2-space) with no formatter config enforcing either. Normalize them with biome (single quotes, 3-space, 100-col) ahead of a logic change to those same files, so the logic commit reads as logic instead of a whole-file style rewrite. Verified: the only non-whitespace differences from the parent are quote style, arrow-parameter parentheses, and trailing commas, all formatter transforms. Co-Authored-By: Claude Opus 4.8 --- controllers/githubHooks.js | 485 +++++++-------- lib/git-manager.js | 1205 +++++++++++++++++------------------- models/pull.js | 573 ++++++++--------- 3 files changed, 1091 insertions(+), 1172 deletions(-) diff --git a/controllers/githubHooks.js b/controllers/githubHooks.js index 82e08e0c..4be2ec1e 100644 --- a/controllers/githubHooks.js +++ b/controllers/githubHooks.js @@ -1,234 +1,227 @@ -import config from "../lib/config-loader.js"; -import Promise from "bluebird"; -import debug from "../lib/debug.js"; -import Pull from "../models/pull.js"; -import Signature from "../models/signature.js"; -import Comment from "../models/comment.js"; -import Review from "../models/review.js"; -import Status from "../models/status.js"; -import Label from "../models/label.js"; -import refresh from "../lib/refresh.js"; -import queue from "../lib/pull-queue.js"; -import getLogin from "../lib/get-user-login.js"; -import utils from "../lib/utils.js"; -import dbManager from "../lib/db-manager.js"; +import config from '../lib/config-loader.js'; +import Promise from 'bluebird'; +import debug from '../lib/debug.js'; +import Pull from '../models/pull.js'; +import Signature from '../models/signature.js'; +import Comment from '../models/comment.js'; +import Review from '../models/review.js'; +import Status from '../models/status.js'; +import Label from '../models/label.js'; +import refresh from '../lib/refresh.js'; +import queue from '../lib/pull-queue.js'; +import getLogin from '../lib/get-user-login.js'; +import utils from '../lib/utils.js'; +import dbManager from '../lib/db-manager.js'; -const hooksDebug = debug("pulldasher:hooks"); +const hooksDebug = debug('pulldasher:hooks'); const HooksController = { - main: function (req, res) { - // Variable for promise that will resolve when the hook is known to have - // succeeded or failed. - var dbUpdated; - var comment; + main: function (req, res) { + // Variable for promise that will resolve when the hook is known to have + // succeeded or failed. + var dbUpdated; + var comment; - var secret = req.query.secret; - if (secret !== config.github.hook_secret) { - var m = "Invalid Hook Secret: " + secret; - hooksDebug(m); - console.error(m); - return res.status(401).send("Invalid POST"); - } + var secret = req.query.secret; + if (secret !== config.github.hook_secret) { + var m = 'Invalid Hook Secret: ' + secret; + hooksDebug(m); + console.error(m); + return res.status(401).send('Invalid POST'); + } - // Begin the webhook decoding - var body; - if (req.headers["content-type"] === "application/json") { - body = req.body; - } else if ( - req.headers["content-type"] === "application/x-www-form-urlencoded" - ) { - body = JSON.parse(req.body.payload); - } else { - return res - .status(400) - .send("Invalid content-type " + req.headers["content-type"]); - } + // Begin the webhook decoding + var body; + if (req.headers['content-type'] === 'application/json') { + body = req.body; + } else if (req.headers['content-type'] === 'application/x-www-form-urlencoded') { + body = JSON.parse(req.body.payload); + } else { + return res.status(400).send('Invalid content-type ' + req.headers['content-type']); + } - var event = req.get("X-GitHub-Event"); - hooksDebug("Received GitHub webhook, Event: %s, Action: %s", event, body.action); + var event = req.get('X-GitHub-Event'); + hooksDebug('Received GitHub webhook, Event: %s, Action: %s', event, body.action); - if (event === "status") { - const updatedStatus = new Status({ - repo: body.name, - sha: body.sha, - state: body.state, - context: body.context, - description: body.description, - target_url: body.target_url, - started_at: body.created_at, - completed_at: body.state == "pending" ? null : body.updated_at, - }); - dbUpdated = dbManager.updateCommitStatus(updatedStatus); - } else if (event === "issues") { - dbUpdated = handleIssueEvent(body); - } else if (event === "pull_request") { - // Promise that resolves when everything that needs to be done before - // we call `updatePull` has finished. - var preUpdate = handleLabelEvents(body); - // New commits may carry a GitHub approval over a clean master merge, so - // after the cheap DB update we re-derive review state from GitHub. - var reconcileAfterUpdate = false; + if (event === 'status') { + const updatedStatus = new Status({ + repo: body.name, + sha: body.sha, + state: body.state, + context: body.context, + description: body.description, + target_url: body.target_url, + started_at: body.created_at, + completed_at: body.state == 'pending' ? null : body.updated_at, + }); + dbUpdated = dbManager.updateCommitStatus(updatedStatus); + } else if (event === 'issues') { + dbUpdated = handleIssueEvent(body); + } else if (event === 'pull_request') { + // Promise that resolves when everything that needs to be done before + // we call `updatePull` has finished. + var preUpdate = handleLabelEvents(body); + // New commits may carry a GitHub approval over a clean master merge, so + // after the cheap DB update we re-derive review state from GitHub. + var reconcileAfterUpdate = false; - switch (body.action) { - case "opened": - case "reopened": - case "closed": - case "edited": - case "merged": - break; + switch (body.action) { + case 'opened': + case 'reopened': + case 'closed': + case 'edited': + case 'merged': + break; - case "synchronize": - // Clear prior CR/QA signoffs immediately for snappy feedback. Emoji - // CR/QA stay cleared; a carried-over GitHub *approval* is restored by - // the full refresh below (see Signature.parseReview / git-manager). - preUpdate = dbManager.invalidateSignatures( - body.repository.full_name, - body.pull_request.number, - ["QA", "CR"] - ); - reconcileAfterUpdate = true; - } + case 'synchronize': + // Clear prior CR/QA signoffs immediately for snappy feedback. Emoji + // CR/QA stay cleared; a carried-over GitHub *approval* is restored by + // the full refresh below (see Signature.parseReview / git-manager). + preUpdate = dbManager.invalidateSignatures( + body.repository.full_name, + body.pull_request.number, + ['QA', 'CR'] + ); + reconcileAfterUpdate = true; + } - // Update DB with new pull request content. - dbUpdated = preUpdate.then(function () { - return dbManager.updatePull(Pull.fromGithubApi(body.pull_request)); - }); + // Update DB with new pull request content. + dbUpdated = preUpdate.then(function () { + return dbManager.updatePull(Pull.fromGithubApi(body.pull_request)); + }); - if (reconcileAfterUpdate) { - // A `synchronize` webhook never triggers a full refresh on its own, - // and Pulldasher has no periodic poll, so re-derive review state from - // GitHub once the cheap DB update lands to pick up an approval GitHub - // kept across the new commit(s). - // - // Fire-and-forget, like every other refresh.pull caller below. Do NOT - // fold it into `dbUpdated`: coupling the webhook's HTTP response to a - // full, serialized refresh risks GitHub's 10s webhook timeout and - // turns a transient refresh failure into a 500 + redelivery storm. - dbUpdated.then(function () { - refresh.pull(body.repository.full_name, body.pull_request.number); - }); - } - } else if (event === "issue_comment") { - if (body.action === "created") { - var promises = []; + if (reconcileAfterUpdate) { + // A `synchronize` webhook never triggers a full refresh on its own, + // and Pulldasher has no periodic poll, so re-derive review state from + // GitHub once the cheap DB update lands to pick up an approval GitHub + // kept across the new commit(s). + // + // Fire-and-forget, like every other refresh.pull caller below. Do NOT + // fold it into `dbUpdated`: coupling the webhook's HTTP response to a + // full, serialized refresh risks GitHub's 10s webhook timeout and + // turns a transient refresh failure into a 500 + redelivery storm. + dbUpdated.then(function () { + refresh.pull(body.repository.full_name, body.pull_request.number); + }); + } + } else if (event === 'issue_comment') { + if (body.action === 'created') { + var promises = []; - // Parse any signature(s) out of the comment. - var sigs = Signature.parseComment( - body.comment, - body.repository.full_name, - body.issue.number - ); - promises.push(dbManager.insertSignatures(sigs)); + // Parse any signature(s) out of the comment. + var sigs = Signature.parseComment( + body.comment, + body.repository.full_name, + body.issue.number + ); + promises.push(dbManager.insertSignatures(sigs)); - body.comment.number = body.issue.number; - body.comment.repo = body.repository.full_name; - body.comment.type = "issue"; - comment = new Comment(body.comment); + body.comment.number = body.issue.number; + body.comment.repo = body.repository.full_name; + body.comment.type = 'issue'; + comment = new Comment(body.comment); - promises.push(dbManager.updateComment(comment)); + promises.push(dbManager.updateComment(comment)); - dbUpdated = Promise.all(promises); - } else { - // If the comment was edited or deleted, the easiest way to deal - // with the result is to simply refresh all data for the pull (or - // issue). Otherwise, we'd have to delete or update the comment, - // delete or update any signatures tied to that comment, then - // delete all signatures and re-insert in order them so the - // dev_blocking and such comes out correct. - refreshPullOrIssue(body); - } - } else if (event === "pull_request_review") { - if (body.action === "submitted") { - promises = []; + dbUpdated = Promise.all(promises); + } else { + // If the comment was edited or deleted, the easiest way to deal + // with the result is to simply refresh all data for the pull (or + // issue). Otherwise, we'd have to delete or update the comment, + // delete or update any signatures tied to that comment, then + // delete all signatures and re-insert in order them so the + // dev_blocking and such comes out correct. + refreshPullOrIssue(body); + } + } else if (event === 'pull_request_review') { + if (body.action === 'submitted') { + promises = []; - // Parse any signature(s) out of the comment. - sigs = Signature.parseReview( - body.review, - body.repository.full_name, - body.pull_request.number - ); - promises.push(dbManager.insertSignatures(sigs)); + // Parse any signature(s) out of the comment. + sigs = Signature.parseReview( + body.review, + body.repository.full_name, + body.pull_request.number + ); + promises.push(dbManager.insertSignatures(sigs)); - body.review.number = body.pull_request.number; - body.review.repo = body.repository.full_name; - const review = new Review(body.review); + body.review.number = body.pull_request.number; + body.review.repo = body.repository.full_name; + const review = new Review(body.review); - promises.push(dbManager.updateReview(review)); + promises.push(dbManager.updateReview(review)); - dbUpdated = Promise.all(promises); - } else { - // If the review was edited or deleted, the easiest way to deal - // with the result is to simply refresh all data for the pull. - // Otherwise, we'd have to delete or update the review, delete or - // update any signatures tied to that review, then delete all - // signatures and re-insert in order them so the dev_blocking and - // such comes out correct. - refreshPullOrIssue(body); - } - } else if (event === "pull_request_review_comment") { - if (body.action === "deleted") { - dbUpdated = dbManager.deleteReviewComment( - body.repository.full_name, - body.comment.id - ); - } else { - body.comment.number = body.pull_request.number; - body.comment.repo = body.repository.full_name; - body.comment.type = "review"; - comment = new Comment(body.comment); + dbUpdated = Promise.all(promises); + } else { + // If the review was edited or deleted, the easiest way to deal + // with the result is to simply refresh all data for the pull. + // Otherwise, we'd have to delete or update the review, delete or + // update any signatures tied to that review, then delete all + // signatures and re-insert in order them so the dev_blocking and + // such comes out correct. + refreshPullOrIssue(body); + } + } else if (event === 'pull_request_review_comment') { + if (body.action === 'deleted') { + dbUpdated = dbManager.deleteReviewComment(body.repository.full_name, body.comment.id); + } else { + body.comment.number = body.pull_request.number; + body.comment.repo = body.repository.full_name; + body.comment.type = 'review'; + comment = new Comment(body.comment); - dbUpdated = dbManager.updateComment(comment); - } - } else if (event === "check_run") { - const conclusion = body.check_run.conclusion || body.check_run.status; - const state = utils.mapCheckToStatus(conclusion); + dbUpdated = dbManager.updateComment(comment); + } + } else if (event === 'check_run') { + const conclusion = body.check_run.conclusion || body.check_run.status; + const state = utils.mapCheckToStatus(conclusion); - const status = new Status({ - repo: body.repository.full_name, - sha: body.check_run.head_sha, - state: state, - context: body.check_run.name, - description: conclusion, - target_url: body.check_run.html_url, - started_at: body.check_run.started_at, - completed_at: body.check_run.completed_at, - }); - dbUpdated = dbManager.updateCommitStatus(status); - } + const status = new Status({ + repo: body.repository.full_name, + sha: body.check_run.head_sha, + state: state, + context: body.check_run.name, + description: conclusion, + target_url: body.check_run.html_url, + started_at: body.check_run.started_at, + completed_at: body.check_run.completed_at, + }); + dbUpdated = dbManager.updateCommitStatus(status); + } - if (dbUpdated) { - dbUpdated - .then( - function fulfilled() { - res.status(200).send("Success!"); - }, - function rejected(err) { - console.log(err); - res.status(500).send(err.toString()); - } - ) - .done(); - } else { - res.status(200).send("Success!"); - } - }, + if (dbUpdated) { + dbUpdated + .then( + function fulfilled() { + res.status(200).send('Success!'); + }, + function rejected(err) { + console.log(err); + res.status(500).send(err.toString()); + } + ) + .done(); + } else { + res.status(200).send('Success!'); + } + }, }; function handleIssueEvent(body) { - hooksDebug("Webhook action: %s for issue #%s", body.action, body.issue.number); + hooksDebug('Webhook action: %s for issue #%s', body.action, body.issue.number); - var doneHandling = handleLabelEvents(body); + var doneHandling = handleLabelEvents(body); - // Always refresh from the API rather than upserting the webhook body - // directly. The body is just a subset of the fields (and for pull requests, - // not even an issue), and it carries no events, which we need to attribute - // labels and to date the current assignment (date_assigned). - // refreshPullOrIssue dispatches pull-vs-issue from the body itself. - return doneHandling.then(function () { - // Not returning here cause we don't want to delay replying to the - // hook with a 200 since we know what needs to be done. - refreshPullOrIssue(body); - }); + // Always refresh from the API rather than upserting the webhook body + // directly. The body is just a subset of the fields (and for pull requests, + // not even an issue), and it carries no events, which we need to attribute + // labels and to date the current assignment (date_assigned). + // refreshPullOrIssue dispatches pull-vs-issue from the body itself. + return doneHandling.then(function () { + // Not returning here cause we don't want to delay replying to the + // hook with a 200 since we know what needs to be done. + refreshPullOrIssue(body); + }); } /** @@ -239,55 +232,53 @@ function handleIssueEvent(body) { * Note: will return a fulfilled promise if this is not a label event. */ function handleLabelEvents(body) { - var object = body.pull_request || body.issue; - switch (body.action) { - case "labeled": - hooksDebug("Added label: %s", body.label.name); - return dbManager - .insertLabel( - new Label( - body.label, - object.number, - body.repository.full_name, - getLogin(body.sender), - object.updated_at - ) - ) - .then(markDirtyIfPull); + var object = body.pull_request || body.issue; + switch (body.action) { + case 'labeled': + hooksDebug('Added label: %s', body.label.name); + return dbManager + .insertLabel( + new Label( + body.label, + object.number, + body.repository.full_name, + getLogin(body.sender), + object.updated_at + ) + ) + .then(markDirtyIfPull); - case "unlabeled": - hooksDebug("Removed label: %s", body.label.name); - return dbManager - .deleteLabel( - new Label(body.label, object.number, body.repository.full_name) - ) - .then(markDirtyIfPull); - } - return Promise.resolve(); + case 'unlabeled': + hooksDebug('Removed label: %s', body.label.name); + return dbManager + .deleteLabel(new Label(body.label, object.number, body.repository.full_name)) + .then(markDirtyIfPull); + } + return Promise.resolve(); - // A label change must broadcast on its own: the weight labels drive the - // frontend's CR-weight display, and the only other thing that notifies - // clients after a label event is an incidental updatePull call in the - // pull_request handler — an easy thing for a refactor to scope away. - function markDirtyIfPull() { - if (body.pull_request) { - queue.markPullAsDirty(body.repository.full_name, object.number); - } - } + // A label change must broadcast on its own: the weight labels drive the + // frontend's CR-weight display, and the only other thing that notifies + // clients after a label event is an incidental updatePull call in the + // pull_request handler — an easy thing for a refactor to scope away. + function markDirtyIfPull() { + if (body.pull_request) { + queue.markPullAsDirty(body.repository.full_name, object.number); + } + } } function refreshPullOrIssue(responseBody) { - var repo = responseBody.repository.full_name; + var repo = responseBody.repository.full_name; - // The Docs: https://developer.github.com/v3/issues/#list-issues say you can - // tell the difference like this: - if (responseBody.pull_request) { - refresh.pull(repo, responseBody.pull_request.number); - } else if (responseBody.issue.pull_request) { - refresh.pull(repo, responseBody.issue.number); - } else { - refresh.issue(repo, responseBody.issue.number); - } + // The Docs: https://developer.github.com/v3/issues/#list-issues say you can + // tell the difference like this: + if (responseBody.pull_request) { + refresh.pull(repo, responseBody.pull_request.number); + } else if (responseBody.issue.pull_request) { + refresh.pull(repo, responseBody.issue.number); + } else { + refresh.issue(repo, responseBody.issue.number); + } } export default HooksController; diff --git a/lib/git-manager.js b/lib/git-manager.js index f969ee5d..c17b87cd 100644 --- a/lib/git-manager.js +++ b/lib/git-manager.js @@ -1,59 +1,55 @@ -import { Octokit } from "@octokit/rest"; -import { throttling } from "@octokit/plugin-throttling"; -import { retry } from "@octokit/plugin-retry"; -import config from "./config-loader.js"; -import Promise from "bluebird"; -import _ from "underscore"; -import debug from "./debug.js"; -import utils from "./utils.js"; -import Pull from "../models/pull.js"; -import Issue from "../models/issue.js"; -import Comment from "../models/comment.js"; -import Review from "../models/review.js"; -import Label from "../models/label.js"; -import Status from "../models/status.js"; -import Signature from "../models/signature.js"; -import getLogin from "./get-user-login.js"; -import { noopPacer } from "./pacer.js"; +import { Octokit } from '@octokit/rest'; +import { throttling } from '@octokit/plugin-throttling'; +import { retry } from '@octokit/plugin-retry'; +import config from './config-loader.js'; +import Promise from 'bluebird'; +import _ from 'underscore'; +import debug from './debug.js'; +import utils from './utils.js'; +import Pull from '../models/pull.js'; +import Issue from '../models/issue.js'; +import Comment from '../models/comment.js'; +import Review from '../models/review.js'; +import Label from '../models/label.js'; +import Status from '../models/status.js'; +import Signature from '../models/signature.js'; +import getLogin from './get-user-login.js'; +import { noopPacer } from './pacer.js'; const MyOctokit = Octokit.plugin(throttling, retry); -const gitDebug = debug("pulldasher:github"); +const gitDebug = debug('pulldasher:github'); console.log(config.github); const github = new MyOctokit({ - auth: config.github.token, - // Auto-retry transient 5xx/network failures (the throttle plugin only covers - // rate limits); the long bulk backfills make thousands of calls, so a single - // transient error shouldn't abort the sweep before it can be reissued. Note: - // the count goes under `retry`, not `request` — `request: { retries }` forces - // retries on every status, bypassing plugin-retry's doNotRetry (4xx) list. - retry: { retries: 5 }, - throttle: { - onRateLimit: (retryAfter, options) => { - github.log.warn( - `Request quota exhausted for request ${options.method} ${options.url}` - ); - - // Retry five times after hitting a rate limit error, then give up - if (options.request.retryCount <= 5) { - github.log.debug(`Retrying after ${retryAfter} seconds!`); - return true; - } - }, - onSecondaryRateLimit: (retryAfter, options) => { - // does not retry, only logs a warning - github.log.warn( - `SecondaryRateLimit detected for request ${options.method} ${options.url}`, - ); - }, - onAbuseLimit: (retryAfter, options) => { - // does not retry, only logs a warning - github.log.warn( - `Abuse detected for request ${options.method} ${options.url}` - ); - }, - }, + auth: config.github.token, + // Auto-retry transient 5xx/network failures (the throttle plugin only covers + // rate limits); the long bulk backfills make thousands of calls, so a single + // transient error shouldn't abort the sweep before it can be reissued. Note: + // the count goes under `retry`, not `request` — `request: { retries }` forces + // retries on every status, bypassing plugin-retry's doNotRetry (4xx) list. + retry: { retries: 5 }, + throttle: { + onRateLimit: (retryAfter, options) => { + github.log.warn(`Request quota exhausted for request ${options.method} ${options.url}`); + + // Retry five times after hitting a rate limit error, then give up + if (options.request.retryCount <= 5) { + github.log.debug(`Retrying after ${retryAfter} seconds!`); + return true; + } + }, + onSecondaryRateLimit: (retryAfter, options) => { + // does not retry, only logs a warning + github.log.warn( + `SecondaryRateLimit detected for request ${options.method} ${options.url}` + ); + }, + onAbuseLimit: (retryAfter, options) => { + // does not retry, only logs a warning + github.log.warn(`Abuse detected for request ${options.method} ${options.url}`); + }, + }, }); // plugin-retry retries transient 5xx/network errors silently (no log of its @@ -61,18 +57,18 @@ const github = new MyOctokit({ // Surface those attempts under pulldasher:github. We only log transient errors // (5xx / network) — the throttle plugin already logs 4xx rate limits — and // rethrow untouched so plugin-retry's retry logic is unaffected. -github.hook.error("request", (error, options) => { - const status = error.status; - if (status === undefined || status >= 500) { - gitDebug( - "Transient request error on %s %s (status %s), attempt %s", - options.method, - options.url, - status === undefined ? "network" : status, - (options.request.retryCount || 0) + 1 - ); - } - throw error; +github.hook.error('request', (error, options) => { + const status = error.status; + if (status === undefined || status >= 500) { + gitDebug( + 'Transient request error on %s %s (status %s), attempt %s', + options.method, + options.url, + status === undefined ? 'network' : status, + (options.request.retryCount || 0) + 1 + ); + } + throw error; }); /** @@ -89,365 +85,353 @@ github.hook.error("request", (error, options) => { */ let rateLimitObserved = false; export function observeRateLimit(pacer) { - if (rateLimitObserved) { - return; - } - rateLimitObserved = true; - github.hook.after("request", (response) => { - pacer.observe(response.headers); - }); - github.hook.error("request", (error) => { - // A failed response still reports the current quota. - pacer.observe(error.response && error.response.headers); - throw error; - }); + if (rateLimitObserved) { + return; + } + rateLimitObserved = true; + github.hook.after('request', response => { + pacer.observe(response.headers); + }); + github.hook.error('request', error => { + // A failed response still reports the current quota. + pacer.observe(error.response && error.response.headers); + throw error; + }); } const githubRest = github.rest; export default { - github: githubRest, - - /** - * Returns a promise which resolves to a GitHub API response to - * a query for a particular Pull Request. - */ - getPull: function (repo, number) { - return logErrors( - githubRest.pulls - .get(params({ pull_number: number }, repo)) - .then((res) => res.data), - "Getting pull %s", - number - ); - }, - - /** - * Get all *open* pull requests for a repo. - * - * Returns a promise which resolves to an array of all open pull requests - */ - getOpenPulls: function (repo, pacer = noopPacer) { - return logErrors( - pacedPaginate(githubRest.pulls.list, params({ state: "open" }, repo), pacer), - "Getting open pulls in repo %s", - repo - ); - }, - - /** - * Get *all* pull requests for a repo. - * - * Returns a promise which resolves to an array of all pull requests - */ - getAllPulls: function (repo, pacer = noopPacer) { - return logErrors( - pacedPaginate(githubRest.pulls.list, params({ state: "all" }, repo), pacer), - "Getting all pulls in repo %s", - repo - ); - }, - - /** - * Best-effort: request `login` as a reviewer on a pull. Fires as the bot - * token — pulldasher keeps no per-user GitHub token — so GitHub records the - * request as added by the app account, with `login` as the requested - * reviewer. Never rejects: a claim must still succeed locally even when - * GitHub refuses the request (422 when the claimer is the PR author, or when - * the login can't be a reviewer on that repo). Returns a promise that always - * resolves. - */ - requestReviewer: function (repo, number, login) { - if (!login) { - return Promise.resolve(); - } - return githubRest.pulls - .requestReviewers(params({ pull_number: number, reviewers: [login] }, repo)) - .then( - function () { - gitDebug("Requested %s as reviewer on %s#%s", login, repo, number); - }, - function (err) { - gitDebug( - "Could not request %s as reviewer on %s#%s: %s", - login, - repo, - number, - (err && err.status) || (err && err.message) || err - ); - } + github: githubRest, + + /** + * Returns a promise which resolves to a GitHub API response to + * a query for a particular Pull Request. + */ + getPull: function (repo, number) { + return logErrors( + githubRest.pulls.get(params({ pull_number: number }, repo)).then(res => res.data), + 'Getting pull %s', + number ); - }, - - /** - * Best-effort inverse of requestReviewer: drop `login`'s review request when - * a claim is released, so passing on a dealt pull (or releasing a claim) - * doesn't leave an orphan review request on GitHub. Never rejects — a "not - * currently requested" response is expected and ignored. - */ - removeReviewer: function (repo, number, login) { - if (!login) { - return Promise.resolve(); - } - return githubRest.pulls - .removeRequestedReviewers( - params({ pull_number: number, reviewers: [login] }, repo) - ) - .then( - function () { - gitDebug("Removed %s review request on %s#%s", login, repo, number); - }, - function (err) { - gitDebug( - "Could not remove %s review request on %s#%s: %s", - login, - repo, - number, - (err && err.status) || (err && err.message) || err - ); - } + }, + + /** + * Get all *open* pull requests for a repo. + * + * Returns a promise which resolves to an array of all open pull requests + */ + getOpenPulls: function (repo, pacer = noopPacer) { + return logErrors( + pacedPaginate(githubRest.pulls.list, params({ state: 'open' }, repo), pacer), + 'Getting open pulls in repo %s', + repo ); - }, - - /** - * Get an issue for a repo. - * - * Returns a promise which resolves to a github issue - */ - getIssue, - - /** - * Get all open issues for a repo. - * - * Returns a promise which resolves to an array of all open issues - */ - getOpenIssues: function (repo, pacer = noopPacer) { - const searchParams = params({ state: "open" }, repo); - return logErrors( - pacedPaginate(githubRest.issues.listForRepo, searchParams, pacer) - .then(filterOutPulls) - .then(addRepo(searchParams)), - "Getting open issues in repo %s", - repo - ); - }, - - /** - * Get *all* issues for a repo. - * - * Returns a promise which resolves to an array of all issues - */ - getAllIssues: function (repo, pacer = noopPacer) { - const searchParams = params({ state: "all" }, repo); - return logErrors( - pacedPaginate(githubRest.issues.listForRepo, searchParams, pacer) - .then(filterOutPulls) - .then(addRepo(searchParams)), - "Getting all issues in repo %s", - repo - ); - }, - - /** - * Takes a promise that resolves to a GitHub pull request API response, - * parses it, and returns a promise that resolves to a Pull objects. - */ - parse: function (githubPull) { - gitDebug( - "Getting all information for pull %s in repo %s", - githubPull.number, - githubPull.base.repo.full_name - ); - // We've occasionally noticed a null pull body, so lets fix it upfront - // before errors happen. - githubPull.body = githubPull.body || ""; - - var repo = githubPull.base.repo.full_name; - - var reviewComments = getPullReviewComments(repo, githubPull.number); - var comments = getIssueComments(repo, githubPull.number); - var headCommit = getCommit(repo, githubPull.head.sha); - var commitStatuses = getCommitStatuses(repo, githubPull.head.sha); - var jobRuns = getAllJobRuns(repo, githubPull.head.sha); - var events = getIssueEvents(repo, githubPull.number); - // Only so we have the canonical list of labels. - var ghIssue = getIssue(repo, githubPull.number); - var reviews = getReviews(repo, githubPull.number); - - // Returned to the map function. Each element of githubPulls maps to - // a promise that resolves to a Pull. - return Promise.all([ - reviewComments, - comments, - headCommit, - commitStatuses, - jobRuns, - events, - ghIssue, - reviews, - ]).then(function (results) { - var reviewComments = results[0], - comments = results[1], - headCommit = results[2], - commitStatuses = results[3], - jobRuns = results[4], - events = results[5], - ghIssue = results[6], - reviews = results[7]; - - // Array of Signature objects. - var commentSignatures = comments.reduce(function (sigs, comment) { - var commentSigs = Signature.parseComment( - comment, - repo, - githubPull.number - ); - - return sigs.concat(commentSigs); - }, []); - - var signatures = reviews.reduce(function (sigs, review) { - var reviewSigs = Signature.parseReview(review, repo, githubPull.number); - - return sigs.concat(reviewSigs); - }, commentSignatures); - - // Signoffs from before the most recent commit are no longer active. - // - // Exception: a CR derived from a GitHub native "Approve" review. GitHub - // carries an approval over a clean master merge (it dismisses the review - // only when the diff actually changes), so its `submitted_at` legitimately - // predates the merge commit. GitHub is the source of truth here: if the - // review is still APPROVED at refresh time, parseReview synthesized this - // signature, which means the approval is still valid. Re-applying the - // commit-date check would drop approvals GitHub deliberately kept. - var headCommitDate = new Date(headCommit.commit.committer.date); - signatures.forEach(function (signature) { - if ( - (signature.data.type === "CR" || signature.data.type === "QA") && - !signature.data.fromGithubApproval && - new Date(signature.data.created_at) < headCommitDate - ) { - signature.data.active = false; - } - }); - - // Array of Comment objects. - comments = comments.map(function (commentData) { - commentData.number = githubPull.number; - commentData.repo = repo; - commentData.type = "issue"; - - return new Comment(commentData); - }); - - // Array of Comment objects. - comments = comments.concat( - reviewComments.map(function (commentData) { - commentData.number = githubPull.number; - commentData.repo = repo; - commentData.type = "review"; - - return new Comment(commentData); - }) + }, + + /** + * Get *all* pull requests for a repo. + * + * Returns a promise which resolves to an array of all pull requests + */ + getAllPulls: function (repo, pacer = noopPacer) { + return logErrors( + pacedPaginate(githubRest.pulls.list, params({ state: 'all' }, repo), pacer), + 'Getting all pulls in repo %s', + repo ); - - reviews = reviews.map(function (reviewData) { - reviewData.number = githubPull.number; - reviewData.repo = repo; - - return new Review(reviewData); + }, + + /** + * Best-effort: request `login` as a reviewer on a pull. Fires as the bot + * token — pulldasher keeps no per-user GitHub token — so GitHub records the + * request as added by the app account, with `login` as the requested + * reviewer. Never rejects: a claim must still succeed locally even when + * GitHub refuses the request (422 when the claimer is the PR author, or when + * the login can't be a reviewer on that repo). Returns a promise that always + * resolves. + */ + requestReviewer: function (repo, number, login) { + if (!login) { + return Promise.resolve(); + } + return githubRest.pulls + .requestReviewers(params({ pull_number: number, reviewers: [login] }, repo)) + .then( + function () { + gitDebug('Requested %s as reviewer on %s#%s', login, repo, number); + }, + function (err) { + gitDebug( + 'Could not request %s as reviewer on %s#%s: %s', + login, + repo, + number, + (err && err.status) || (err && err.message) || err + ); + } + ); + }, + + /** + * Best-effort inverse of requestReviewer: drop `login`'s review request when + * a claim is released, so passing on a dealt pull (or releasing a claim) + * doesn't leave an orphan review request on GitHub. Never rejects — a "not + * currently requested" response is expected and ignored. + */ + removeReviewer: function (repo, number, login) { + if (!login) { + return Promise.resolve(); + } + return githubRest.pulls + .removeRequestedReviewers(params({ pull_number: number, reviewers: [login] }, repo)) + .then( + function () { + gitDebug('Removed %s review request on %s#%s', login, repo, number); + }, + function (err) { + gitDebug( + 'Could not remove %s review request on %s#%s: %s', + login, + repo, + number, + (err && err.status) || (err && err.message) || err + ); + } + ); + }, + + /** + * Get an issue for a repo. + * + * Returns a promise which resolves to a github issue + */ + getIssue, + + /** + * Get all open issues for a repo. + * + * Returns a promise which resolves to an array of all open issues + */ + getOpenIssues: function (repo, pacer = noopPacer) { + const searchParams = params({ state: 'open' }, repo); + return logErrors( + pacedPaginate(githubRest.issues.listForRepo, searchParams, pacer) + .then(filterOutPulls) + .then(addRepo(searchParams)), + 'Getting open issues in repo %s', + repo + ); + }, + + /** + * Get *all* issues for a repo. + * + * Returns a promise which resolves to an array of all issues + */ + getAllIssues: function (repo, pacer = noopPacer) { + const searchParams = params({ state: 'all' }, repo); + return logErrors( + pacedPaginate(githubRest.issues.listForRepo, searchParams, pacer) + .then(filterOutPulls) + .then(addRepo(searchParams)), + 'Getting all issues in repo %s', + repo + ); + }, + + /** + * Takes a promise that resolves to a GitHub pull request API response, + * parses it, and returns a promise that resolves to a Pull objects. + */ + parse: function (githubPull) { + gitDebug( + 'Getting all information for pull %s in repo %s', + githubPull.number, + githubPull.base.repo.full_name + ); + // We've occasionally noticed a null pull body, so lets fix it upfront + // before errors happen. + githubPull.body = githubPull.body || ''; + + var repo = githubPull.base.repo.full_name; + + var reviewComments = getPullReviewComments(repo, githubPull.number); + var comments = getIssueComments(repo, githubPull.number); + var headCommit = getCommit(repo, githubPull.head.sha); + var commitStatuses = getCommitStatuses(repo, githubPull.head.sha); + var jobRuns = getAllJobRuns(repo, githubPull.head.sha); + var events = getIssueEvents(repo, githubPull.number); + // Only so we have the canonical list of labels. + var ghIssue = getIssue(repo, githubPull.number); + var reviews = getReviews(repo, githubPull.number); + + // Returned to the map function. Each element of githubPulls maps to + // a promise that resolves to a Pull. + return Promise.all([ + reviewComments, + comments, + headCommit, + commitStatuses, + jobRuns, + events, + ghIssue, + reviews, + ]).then(function (results) { + var reviewComments = results[0], + comments = results[1], + headCommit = results[2], + commitStatuses = results[3], + jobRuns = results[4], + events = results[5], + ghIssue = results[6], + reviews = results[7]; + + // Array of Signature objects. + var commentSignatures = comments.reduce(function (sigs, comment) { + var commentSigs = Signature.parseComment(comment, repo, githubPull.number); + + return sigs.concat(commentSigs); + }, []); + + var signatures = reviews.reduce(function (sigs, review) { + var reviewSigs = Signature.parseReview(review, repo, githubPull.number); + + return sigs.concat(reviewSigs); + }, commentSignatures); + + // Signoffs from before the most recent commit are no longer active. + // + // Exception: a CR derived from a GitHub native "Approve" review. GitHub + // carries an approval over a clean master merge (it dismisses the review + // only when the diff actually changes), so its `submitted_at` legitimately + // predates the merge commit. GitHub is the source of truth here: if the + // review is still APPROVED at refresh time, parseReview synthesized this + // signature, which means the approval is still valid. Re-applying the + // commit-date check would drop approvals GitHub deliberately kept. + var headCommitDate = new Date(headCommit.commit.committer.date); + signatures.forEach(function (signature) { + if ( + (signature.data.type === 'CR' || signature.data.type === 'QA') && + !signature.data.fromGithubApproval && + new Date(signature.data.created_at) < headCommitDate + ) { + signature.data.active = false; + } + }); + + // Array of Comment objects. + comments = comments.map(function (commentData) { + commentData.number = githubPull.number; + commentData.repo = repo; + commentData.type = 'issue'; + + return new Comment(commentData); + }); + + // Array of Comment objects. + comments = comments.concat( + reviewComments.map(function (commentData) { + commentData.number = githubPull.number; + commentData.repo = repo; + commentData.type = 'review'; + + return new Comment(commentData); + }) + ); + + reviews = reviews.map(function (reviewData) { + reviewData.number = githubPull.number; + reviewData.repo = repo; + + return new Review(reviewData); + }); + + let statuses = commitStatuses.map(function (commitStatus) { + let state = commitStatus.state; + let desc = commitStatus.description; + let url = commitStatus.target_url; + let context = commitStatus.context; + + return new Status({ + repo: repo, + sha: githubPull.head.sha, + state: state, + description: desc, + target_url: url, + context: context, + started_at: commitStatus.created_at, + completed_at: state == 'pending' ? null : commitStatus.updated_at, + }); + }); + + let checks = jobRuns.map(function (jobRun) { + let conclusion = jobRun.conclusion || jobRun.status; + let state = utils.mapCheckToStatus(conclusion); + let desc = conclusion; + let url = jobRun.html_url; + let context = jobRun.name; + + return new Status({ + repo: repo, + sha: githubPull.head.sha, + state: state, + description: desc, + target_url: url, + context: context, + started_at: jobRun.started_at, + completed_at: jobRun.completed_at, + }); + }); + + let allCommitStatuses = statuses.concat(checks); + + // Array of Label objects. + const labels = getLabelsFromEvents(events, ghIssue); + + const pull = Pull.fromGithubApi( + githubPull, + signatures, + comments, + reviews, + allCommitStatuses, + labels + ); + return pull.syncToIssue(); }); - - let statuses = commitStatuses.map(function (commitStatus) { - let state = commitStatus.state; - let desc = commitStatus.description; - let url = commitStatus.target_url; - let context = commitStatus.context; - - return new Status({ - repo: repo, - sha: githubPull.head.sha, - state: state, - description: desc, - target_url: url, - context: context, - started_at: commitStatus.created_at, - completed_at: state == "pending" ? null : commitStatus.updated_at, - }); + }, + + /** + * Takes a GitHub issue API response + * parses it, and returns a promise that resolves to an Issue object. + */ + parseIssue: function (ghIssue) { + gitDebug('Getting all information for issue %s in repo %s', ghIssue.number, ghIssue.repo); + return getIssueEvents(ghIssue.repo, ghIssue.number).then(function (events) { + // Array of Label objects. + // Note: using the repo name from the config for now until we support + // multiple repos. The ghIssue object doesn't contain the repo name. + var labels = getLabelsFromEvents(events, ghIssue); + + // The issue object has no assignment timestamp; derive it from events. + ghIssue.date_assigned = getAssignedDateFromEvents(events, ghIssue); + + return Issue.getFromGH(ghIssue, labels); }); - - let checks = jobRuns.map(function (jobRun) { - let conclusion = jobRun.conclusion || jobRun.status; - let state = utils.mapCheckToStatus(conclusion); - let desc = conclusion; - let url = jobRun.html_url; - let context = jobRun.name; - - return new Status({ - repo: repo, - sha: githubPull.head.sha, - state: state, - description: desc, - target_url: url, - context: context, - started_at: jobRun.started_at, - completed_at: jobRun.completed_at, - }); - }); - - let allCommitStatuses = statuses.concat(checks); - - // Array of Label objects. - const labels = getLabelsFromEvents(events, ghIssue); - - const pull = Pull.fromGithubApi( - githubPull, - signatures, - comments, - reviews, - allCommitStatuses, - labels - ); - return pull.syncToIssue(); - }); - }, - - /** - * Takes a GitHub issue API response - * parses it, and returns a promise that resolves to an Issue object. - */ - parseIssue: function (ghIssue) { - gitDebug( - "Getting all information for issue %s in repo %s", - ghIssue.number, - ghIssue.repo - ); - return getIssueEvents(ghIssue.repo, ghIssue.number).then(function (events) { - // Array of Label objects. - // Note: using the repo name from the config for now until we support - // multiple repos. The ghIssue object doesn't contain the repo name. - var labels = getLabelsFromEvents(events, ghIssue); - - // The issue object has no assignment timestamp; derive it from events. - ghIssue.date_assigned = getAssignedDateFromEvents(events, ghIssue); - - return Issue.getFromGH(ghIssue, labels); - }); - }, + }, }; function getIssue(repo, number) { - const searchParams = params({ issue_number: number }, repo); - return logErrors( - githubRest.issues - .get(searchParams) - .then((res) => res.data) - .then(addRepo(searchParams)), - "Getting issue %s in repo %s", - number, - repo - ); + const searchParams = params({ issue_number: number }, repo); + return logErrors( + githubRest.issues + .get(searchParams) + .then(res => res.data) + .then(addRepo(searchParams)), + 'Getting issue %s in repo %s', + number, + repo + ); } /** @@ -458,75 +442,75 @@ function getIssue(repo, number) { * issue.repo property. */ function getLabelsFromEvents(events, ghIssue) { - gitDebug( - "Extracting label assignments from %s issue events for #%s", - events.length, - ghIssue.number - ); - - // Narrow list to relevant labeled/unlabeled events. - events = _.filter(events, function (event) { - return event.event === "labeled" || event.event === "unlabeled"; - }); - - gitDebug("Found %s label events for #%s", events.length, ghIssue.number); - - // Build simple Event objects with all the info we care about. - events = events.map(function (event) { - return { - type: event.event, - name: event.label.name, - user: getLogin(event.actor), - created_at: utils.fromDateString(event.created_at), - }; - }); - - // Group label events by label name. - var labels = _.groupBy(events, "name"); - - // Get a list of the most recent events for each label. - labels = _.map(labels, function (events) { - events = _.sortBy(events, "created_at"); - return _.last(events); - }); - - labels = _.filter(labels, function (event) { - return event.type === "labeled"; - }); - - gitDebug("Found %s unique labels for #%s", labels.length, ghIssue.number); - - // If these are available, use them as the canonical source, only augmented - // by the data from events. If a label is renamed, the events will retain - // the old name but the list of labels on the issue itself will be correct. - // So, if a label is renamed, we'll lose the labeler and the date. - if (ghIssue.labels && ghIssue.labels.length) { - gitDebug("Using %s labels from the github issue", ghIssue.labels.length); - // Includes labeller and a time from the events api - var eventLabels = _.indexBy(labels, "name"); - - return ghIssue.labels.map(function (label) { - var eventLabel = eventLabels[label.name]; + gitDebug( + 'Extracting label assignments from %s issue events for #%s', + events.length, + ghIssue.number + ); + + // Narrow list to relevant labeled/unlabeled events. + events = _.filter(events, function (event) { + return event.event === 'labeled' || event.event === 'unlabeled'; + }); + + gitDebug('Found %s label events for #%s', events.length, ghIssue.number); + + // Build simple Event objects with all the info we care about. + events = events.map(function (event) { + return { + type: event.event, + name: event.label.name, + user: getLogin(event.actor), + created_at: utils.fromDateString(event.created_at), + }; + }); + + // Group label events by label name. + var labels = _.groupBy(events, 'name'); + + // Get a list of the most recent events for each label. + labels = _.map(labels, function (events) { + events = _.sortBy(events, 'created_at'); + return _.last(events); + }); + + labels = _.filter(labels, function (event) { + return event.type === 'labeled'; + }); + + gitDebug('Found %s unique labels for #%s', labels.length, ghIssue.number); + + // If these are available, use them as the canonical source, only augmented + // by the data from events. If a label is renamed, the events will retain + // the old name but the list of labels on the issue itself will be correct. + // So, if a label is renamed, we'll lose the labeler and the date. + if (ghIssue.labels && ghIssue.labels.length) { + gitDebug('Using %s labels from the github issue', ghIssue.labels.length); + // Includes labeller and a time from the events api + var eventLabels = _.indexBy(labels, 'name'); + + return ghIssue.labels.map(function (label) { + var eventLabel = eventLabels[label.name]; + return new Label( + { name: label.name }, + ghIssue.number, + ghIssue.repo, + eventLabel && eventLabel.user, + eventLabel && eventLabel.created_at + ); + }); + } + + // Construct Label objects. + return labels.map(function (label) { return new Label( - { name: label.name }, - ghIssue.number, - ghIssue.repo, - eventLabel && eventLabel.user, - eventLabel && eventLabel.created_at + { name: label.name }, + ghIssue.number, + ghIssue.repo, + label.user, + label.created_at ); - }); - } - - // Construct Label objects. - return labels.map(function (label) { - return new Label( - { name: label.name }, - ghIssue.number, - ghIssue.repo, - label.user, - label.created_at - ); - }); + }); } /** @@ -538,36 +522,32 @@ function getLabelsFromEvents(events, ghIssue) { * null when the issue is unassigned or no matching event exists. */ function getAssignedDateFromEvents(events, ghIssue) { - var assignee = ghIssue.assignee && ghIssue.assignee.login; - if (!assignee) { - return null; - } - - var assignments = _.filter(events, function (event) { - return ( - event.event === "assigned" && - event.assignee && - event.assignee.login === assignee - ); - }); - - var latest = _.last(_.sortBy(assignments, "created_at")); - return latest ? latest.created_at : null; + var assignee = ghIssue.assignee && ghIssue.assignee.login; + if (!assignee) { + return null; + } + + var assignments = _.filter(events, function (event) { + return event.event === 'assigned' && event.assignee && event.assignee.login === assignee; + }); + + var latest = _.last(_.sortBy(assignments, 'created_at')); + return latest ? latest.created_at : null; } /** * Return the default api params merged with the overrides */ function params(apiParams, fullRepoName) { - const [owner, repo] = parseRepo(fullRepoName); - - return _.extend( - { - owner, - repo, - }, - apiParams - ); + const [owner, repo] = parseRepo(fullRepoName); + + return _.extend( + { + owner, + repo, + }, + apiParams + ); } /** @@ -578,160 +558,143 @@ function params(apiParams, fullRepoName) { * object. */ function addRepo({ owner, repo }) { - function addRepositoryField(ghIssue) { - ghIssue.repo = owner + "/" + repo; - } - return function (results) { - if (Array.isArray(results)) { - results.forEach(addRepositoryField); - } else { - addRepositoryField(results); - } - return results; - }; + function addRepositoryField(ghIssue) { + ghIssue.repo = owner + '/' + repo; + } + return function (results) { + if (Array.isArray(results)) { + results.forEach(addRepositoryField); + } else { + addRepositoryField(results); + } + return results; + }; } /** * Splits the repo into the owner and repo name. */ function parseRepo(repo) { - return repo.split("/"); + return repo.split('/'); } /** * Return a promise for all issue events for the given issue / pull */ function getIssueEvents(repo, number) { - return logErrors( - github.paginate( - githubRest.issues.listEvents, - params({ issue_number: number }, repo) - ), - "Getting events for issue %s:%s", - repo, - number - ); + return logErrors( + github.paginate(githubRest.issues.listEvents, params({ issue_number: number }, repo)), + 'Getting events for issue %s:%s', + repo, + number + ); } function getIssueComments(repo, number) { - return logErrors( - github.paginate( - githubRest.issues.listComments, - params({ issue_number: number }, repo) - ), - "Getting comments for issue %s:%s", - repo, - number - ); + return logErrors( + github.paginate(githubRest.issues.listComments, params({ issue_number: number }, repo)), + 'Getting comments for issue %s:%s', + repo, + number + ); } function getReviews(repo, number) { - return logErrors( - github.paginate( - githubRest.pulls.listReviews, - params({ pull_number: number }, repo) - ), - "Getting reviews for pull %s:%s", - repo, - number - ); + return logErrors( + github.paginate(githubRest.pulls.listReviews, params({ pull_number: number }, repo)), + 'Getting reviews for pull %s:%s', + repo, + number + ); } function getPullReviewComments(repo, number) { - return logErrors( - github.paginate( - githubRest.pulls.listReviewComments, - params({ pull_number: number }, repo) - ), - "Getting pull review comments for pull %s:%s", - repo, - number - ); + return logErrors( + github.paginate(githubRest.pulls.listReviewComments, params({ pull_number: number }, repo)), + 'Getting pull review comments for pull %s:%s', + repo, + number + ); } function getCommit(repo, sha) { - return logErrors( - githubRest.repos - .getCommit(params({ ref: sha }, repo)) - .then((res) => res.data), - "Getting commit for %s:%s", - repo, - sha - ); + return logErrors( + githubRest.repos.getCommit(params({ ref: sha }, repo)).then(res => res.data), + 'Getting commit for %s:%s', + repo, + sha + ); } function getCommitStatuses(repo, ref) { - return logErrors( - githubRest.repos - .getCombinedStatusForRef(params({ ref }, repo)) - .then((res) => res.data.statuses) - .then((statuses) => statuses || []), - "Getting commit status for %s:%s", - repo, - ref - ); + return logErrors( + githubRest.repos + .getCombinedStatusForRef(params({ ref }, repo)) + .then(res => res.data.statuses) + .then(statuses => statuses || []), + 'Getting commit status for %s:%s', + repo, + ref + ); } function getAllJobRuns(repo, ref) { - return logErrors( - github - .paginate( - githubRest.actions.listWorkflowRunsForRepo, - params( - { - head_sha: ref, - exclude_pull_requests: true, - }, - repo - ) - ) - .then((runs) => Promise.all((runs || []).map(getJobRunsFromWorkflow))) - .then((runs) => runs.flat(1)), - "Getting workflow runs for %s:%s", - repo, - ref - ); + return logErrors( + github + .paginate( + githubRest.actions.listWorkflowRunsForRepo, + params( + { + head_sha: ref, + exclude_pull_requests: true, + }, + repo + ) + ) + .then(runs => Promise.all((runs || []).map(getJobRunsFromWorkflow))) + .then(runs => runs.flat(1)), + 'Getting workflow runs for %s:%s', + repo, + ref + ); } function getJobRunsFromWorkflow(workflowRun) { - return logErrors( - github - .paginate( - githubRest.actions.listJobsForWorkflowRun, - params( - { - run_id: workflowRun.id, - }, - workflowRun.repository.full_name - ) - ) - .then((jobs) => jobs || []), - "Getting jobs runs for %s:%s", - workflowRun.repository.full_name, - workflowRun.name - ); + return logErrors( + github + .paginate( + githubRest.actions.listJobsForWorkflowRun, + params( + { + run_id: workflowRun.id, + }, + workflowRun.repository.full_name + ) + ) + .then(jobs => jobs || []), + 'Getting jobs runs for %s:%s', + workflowRun.repository.full_name, + workflowRun.name + ); } /** * Remove all entries that have the pull_request key set to something truthy */ function filterOutPulls(issues) { - gitDebug("Filtering out pulls from list of %s issues", issues.length); - issues = _.filter( - issues, - (issue) => !issue.pull_request || !issue.pull_request.url - ); - gitDebug("Filtered down to %s issues", issues.length); - return issues; + gitDebug('Filtering out pulls from list of %s issues', issues.length); + issues = _.filter(issues, issue => !issue.pull_request || !issue.pull_request.url); + gitDebug('Filtered down to %s issues', issues.length); + return issues; } function logErrors(promise, ...messageAndArgs) { - gitDebug(...messageAndArgs); - return promise.catch((err) => { - messageAndArgs[0] = "Error: Failed while: " + messageAndArgs[0]; - gitDebug(...messageAndArgs); - throw err; - }); + gitDebug(...messageAndArgs); + return promise.catch(err => { + messageAndArgs[0] = 'Error: Failed while: ' + messageAndArgs[0]; + gitDebug(...messageAndArgs); + throw err; + }); } /** @@ -747,14 +710,14 @@ function logErrors(promise, ...messageAndArgs) { * path, which never lists whole repos anyway). */ async function pacedPaginate(route, parameters, pacer = noopPacer) { - const items = []; - for await (const response of github.paginate.iterator(route, parameters)) { - items.push(...response.data); - if (hasNextPage(response)) { - await pacer.gate(); - } - } - return items; + const items = []; + for await (const response of github.paginate.iterator(route, parameters)) { + items.push(...response.data); + if (hasNextPage(response)) { + await pacer.gate(); + } + } + return items; } /** @@ -762,6 +725,6 @@ async function pacedPaginate(route, parameters, pacer = noopPacer) { * Link-header signal Octokit's own iterator uses to decide whether to continue. */ function hasNextPage(response) { - const link = response.headers && response.headers.link; - return Boolean(link) && link.includes('rel="next"'); + const link = response.headers && response.headers.link; + return Boolean(link) && link.includes('rel="next"'); } diff --git a/models/pull.js b/models/pull.js index 64b7386f..dd0fc18a 100644 --- a/models/pull.js +++ b/models/pull.js @@ -1,129 +1,119 @@ -import utils from "../lib/utils.js"; -import _ from "underscore"; -import config from "../lib/config-loader.js"; -import queue from "../lib/pull-queue.js"; -import Promise from "bluebird"; -import debug from "../lib/debug.js"; -import DBPull from "./db_pull.js"; -import getLogin from "../lib/get-user-login.js"; +import utils from '../lib/utils.js'; +import _ from 'underscore'; +import config from '../lib/config-loader.js'; +import queue from '../lib/pull-queue.js'; +import Promise from 'bluebird'; +import debug from '../lib/debug.js'; +import DBPull from './db_pull.js'; +import getLogin from '../lib/get-user-login.js'; -const log = debug("pulldasher:pull"); +const log = debug('pulldasher:pull'); class Pull { - constructor(data, signatures, comments, reviews, commitStatuses, labels) { - this.data = data; - this.signatures = signatures || []; - this.comments = comments || []; - this.reviews = reviews || []; - this.commitStatuses = commitStatuses || []; - this.labels = labels || []; + constructor(data, signatures, comments, reviews, commitStatuses, labels) { + this.data = data; + this.signatures = signatures || []; + this.comments = comments || []; + this.reviews = reviews || []; + this.commitStatuses = commitStatuses || []; + this.labels = labels || []; - // If github pull-data, parse the body for the cr and qa req... else - // use the values stored in the db. - if (typeof data.cr_req === "undefined") { - const bodyTags = Pull.parseBody(this.data.body); - this.data.cr_req = bodyTags["cr_req"]; - this.data.qa_req = bodyTags["qa_req"]; - this.data.closes = bodyTags["closes"]; - this.data.connects = bodyTags["connects"]; - } else { - this.data.cr_req = data.cr_req; - this.data.qa_req = data.qa_req; - this.data.closes = data.closes; - this.data.connects = data.connects; - } - this.data.participants = this.collectParticipants(); - this.identifySignatures(); - } + // If github pull-data, parse the body for the cr and qa req... else + // use the values stored in the db. + if (typeof data.cr_req === 'undefined') { + const bodyTags = Pull.parseBody(this.data.body); + this.data.cr_req = bodyTags['cr_req']; + this.data.qa_req = bodyTags['qa_req']; + this.data.closes = bodyTags['closes']; + this.data.connects = bodyTags['connects']; + } else { + this.data.cr_req = data.cr_req; + this.data.qa_req = data.qa_req; + this.data.closes = data.closes; + this.data.connects = data.connects; + } + this.data.participants = this.collectParticipants(); + this.identifySignatures(); + } - update() { - log( - "Calling `updatePull` for pull #%s in repo %s", - this.data.number, - this.data.repo - ); - const dbPull = new DBPull(this); - const number = dbPull.data.number; - const repo = dbPull.data.repo; + update() { + log('Calling `updatePull` for pull #%s in repo %s', this.data.number, this.data.repo); + const dbPull = new DBPull(this); + const number = dbPull.data.number; + const repo = dbPull.data.repo; - return dbPull.save().then(() => { - queue.markPullAsDirty(repo, number); - log("updatePull: Pull #%s updated in repo %s", number, repo); - }); - } + return dbPull.save().then(() => { + queue.markPullAsDirty(repo, number); + log('updatePull: Pull #%s updated in repo %s', number, repo); + }); + } - /** - * Sets sig.data.source_type for each comment, determining where it came from - * by checking for the presence of the comment_id in the list of review ids - * from the DB. - * - * TODO: add a column on signatures table for this value, instead of testing - * for presence in our reviews list. - */ - identifySignatures() { - const reviewIds = new Set( - this.reviews.map((review) => review.data.review_id) - ); - this.signatures.forEach((sig) => { - sig.data.source_type = reviewIds.has(sig.data.comment_id) - ? "review" - : "comment"; - }); - } + /** + * Sets sig.data.source_type for each comment, determining where it came from + * by checking for the presence of the comment_id in the list of review ids + * from the DB. + * + * TODO: add a column on signatures table for this value, instead of testing + * for presence in our reviews list. + */ + identifySignatures() { + const reviewIds = new Set(this.reviews.map(review => review.data.review_id)); + this.signatures.forEach(sig => { + sig.data.source_type = reviewIds.has(sig.data.comment_id) ? 'review' : 'comment'; + }); + } - collectParticipants() { - const participants = new Set(); - this.comments.forEach((comment) => { - participants.add(comment.data.user.login); - }); - this.reviews.forEach((review) => { - participants.add(review.data.user.login); - }); - return Array.from(participants); - } + collectParticipants() { + const participants = new Set(); + this.comments.forEach(comment => { + participants.add(comment.data.user.login); + }); + this.reviews.forEach(review => { + participants.add(review.data.user.login); + }); + return Array.from(participants); + } - /** - * Reviewers whose latest review verdict isn't otherwise visible on the - * board. An APPROVED review already surfaces as an active CR signature - * (Signature.parseReview synthesizes one), so this only needs to carry the - * verdicts that vanish today: CHANGES_REQUESTED, COMMENTED, DISMISSED, etc. - * One entry per distinct reviewer login (their most recent review by date), - * excluding the pull author and anyone who already holds an active CR - * signature (they're already counted there). - */ - collectUnstampedReviewers() { - const authorLogin = this.data.user && this.data.user.login; - const activeCrLogins = new Set( - this.getSignatures("CR").map((sig) => sig.data.user.login) - ); + /** + * Reviewers whose latest review verdict isn't otherwise visible on the + * board. An APPROVED review already surfaces as an active CR signature + * (Signature.parseReview synthesizes one), so this only needs to carry the + * verdicts that vanish today: CHANGES_REQUESTED, COMMENTED, DISMISSED, etc. + * One entry per distinct reviewer login (their most recent review by date), + * excluding the pull author and anyone who already holds an active CR + * signature (they're already counted there). + */ + collectUnstampedReviewers() { + const authorLogin = this.data.user && this.data.user.login; + const activeCrLogins = new Set(this.getSignatures('CR').map(sig => sig.data.user.login)); - const latestByLogin = new Map(); - (this.reviews || []).forEach((review) => { - const login = review.data.user.login; - if (!login || login === authorLogin) return; - const existing = latestByLogin.get(login); - if (!existing || review.data.submitted_at > existing.data.submitted_at) { - latestByLogin.set(login, review); - } - }); + const latestByLogin = new Map(); + (this.reviews || []).forEach(review => { + const login = review.data.user.login; + if (!login || login === authorLogin) return; + const existing = latestByLogin.get(login); + if (!existing || review.data.submitted_at > existing.data.submitted_at) { + latestByLogin.set(login, review); + } + }); - const unstamped = []; - latestByLogin.forEach((review, login) => { - if (activeCrLogins.has(login)) return; - unstamped.push({ - login, - state: review.data.state, - date: Math.floor(review.data.submitted_at.getTime() / 1000), - review_id: review.data.review_id, - body: review.data.body ? review.data.body.slice(0, 400) : undefined, + const unstamped = []; + latestByLogin.forEach((review, login) => { + if (activeCrLogins.has(login)) return; + unstamped.push({ + login, + state: review.data.state, + date: Math.floor(review.data.submitted_at.getTime() / 1000), + review_id: review.data.review_id, + body: review.data.body ? review.data.body.slice(0, 400) : undefined, + }); }); - }); - return unstamped; - } + return unstamped; + } - syncToIssue() { - return Promise.resolve(this); - /* The below needs to be rethought a bit and possibly the causal direction + syncToIssue() { + return Promise.resolve(this); + /* The below needs to be rethought a bit and possibly the causal direction * reversed (updates to the issue should propagate to the pull). var Issue = require('./issue'); var self = this; @@ -148,211 +138,186 @@ class Pull { return new Promise.resolve(self); } */ - } + } - toObject() { - var data = _.extend({}, this.data); - data.status = this.getStatus(); - data.labels = this.labels.map((label) => label.data); - return data; - } + toObject() { + var data = _.extend({}, this.data); + data.status = this.getStatus(); + data.labels = this.labels.map(label => label.data); + return data; + } - /** - * Get all signatures of a given tag. - */ - getSignatures(tagName) { - return this.getAllSignatures(tagName).filter((signature) => { - return signature.data.active === 1; - }); - } + /** + * Get all signatures of a given tag. + */ + getSignatures(tagName) { + return this.getAllSignatures(tagName).filter(signature => { + return signature.data.active === 1; + }); + } - getAllSignatures(tagName) { - return this.signatures.filter((signature) => { - return signature.data.type === tagName; - }); - } + getAllSignatures(tagName) { + return this.signatures.filter(signature => { + return signature.data.type === tagName; + }); + } - isOpen() { - return this.data.state === "open"; - } + isOpen() { + return this.data.state === 'open'; + } - /** - * Return an object: - * { - * 'qa_req' : The *needed* number of QA signatures occuring after the last commit - * in order for this pull to be ready for deploy. - * 'cr_req' : The *needed* number of CR signatures occuring after the last commit - * in order for this pull to be ready for deploy. - * 'dev_block' : An array containing the last 'dev_block' signature if the pull is dev blocked, - * or an empty array - * 'deploy_block' : An array containing the last 'deploy_block' signature if pull is deploy blocked, - * or an empty array - * 'commit_statuses' : An array of Status objects - * } - */ - getStatus() { - var status = { - qa_req: this.data.qa_req, - cr_req: this.data.cr_req, - allQA: this.getAllSignatures("QA"), - allCR: this.getAllSignatures("CR"), - dev_block: this.getSignatures("dev_block"), - deploy_block: this.getSignatures("deploy_block"), - commit_statuses: this.commitStatuses, - // Discussion aggregates. The comment rows are already loaded for - // participants/signature tagging but never shipped; the board only - // needs these two facts (a "quiet since" signal), not the rows. - comment_count: this.comments.length, - last_comment_at: this.comments.length - ? new Date( - Math.max(...this.comments.map((c) => c.data.created_at.getTime())) - ) - : null, - // Additive, unstamped review verdicts (CHANGES_REQUESTED/COMMENTED/ - // DISMISSED) that today have no other visibility on the board. Older - // frontends ignore unknown status fields, so this is safe to ship - // unconditionally. - unstamped_reviewers: this.collectUnstampedReviewers(), - }; + /** + * Return an object: + * { + * 'qa_req' : The *needed* number of QA signatures occuring after the last commit + * in order for this pull to be ready for deploy. + * 'cr_req' : The *needed* number of CR signatures occuring after the last commit + * in order for this pull to be ready for deploy. + * 'dev_block' : An array containing the last 'dev_block' signature if the pull is dev blocked, + * or an empty array + * 'deploy_block' : An array containing the last 'deploy_block' signature if pull is deploy blocked, + * or an empty array + * 'commit_statuses' : An array of Status objects + * } + */ + getStatus() { + var status = { + qa_req: this.data.qa_req, + cr_req: this.data.cr_req, + allQA: this.getAllSignatures('QA'), + allCR: this.getAllSignatures('CR'), + dev_block: this.getSignatures('dev_block'), + deploy_block: this.getSignatures('deploy_block'), + commit_statuses: this.commitStatuses, + // Discussion aggregates. The comment rows are already loaded for + // participants/signature tagging but never shipped; the board only + // needs these two facts (a "quiet since" signal), not the rows. + comment_count: this.comments.length, + last_comment_at: this.comments.length + ? new Date(Math.max(...this.comments.map(c => c.data.created_at.getTime()))) + : null, + // Additive, unstamped review verdicts (CHANGES_REQUESTED/COMMENTED/ + // DISMISSED) that today have no other visibility on the board. Older + // frontends ignore unknown status fields, so this is safe to ship + // unconditionally. + unstamped_reviewers: this.collectUnstampedReviewers(), + }; - return status; - } + return status; + } - /** - * Parse body of Pull Request for special tags (e.g. cr_req, qa_req). - */ - static parseBody(body) { - var bodyTags = []; + /** + * Parse body of Pull Request for special tags (e.g. cr_req, qa_req). + */ + static parseBody(body) { + var bodyTags = []; - config.body_tags.forEach((tag) => { - var matches = body && body.match(tag.regex); + config.body_tags.forEach(tag => { + var matches = body && body.match(tag.regex); - if (matches) { - bodyTags[tag.name] = matches[1]; - } else { - bodyTags[tag.name] = tag.default; - } - }); + if (matches) { + bodyTags[tag.name] = matches[1]; + } else { + bodyTags[tag.name] = tag.default; + } + }); - return bodyTags; - } + return bodyTags; + } - static fromGithubApi( - data, - signatures, - comments, - reviews, - commitStatuses, - labels - ) { - data = { - repo: data.base.repo.full_name, - number: data.number, - state: data.state, - title: data.title, - body: data.body || "", - draft: data.draft, - created_at: utils.fromDateString(data.created_at), - updated_at: utils.fromDateString(data.updated_at), - closed_at: utils.fromDateString(data.closed_at), - mergeable: data.mergeable, - merged_at: utils.fromDateString(data.merged_at), - difficulty: data.difficulty, - milestone: { - title: data.milestone && data.milestone.title, - due_on: data.milestone && utils.fromDateString(data.milestone.due_on), - }, - head: { - ref: data.head.ref, - sha: data.head.sha, - repo: { - owner: { - login: data.head.repo.owner.login, - }, - name: data.head.repo.name, - }, - }, - base: { - ref: data.base.ref, - }, - user: { - login: getLogin(data.user), - }, - assignees: (data.assignees || []).map((a) => getLogin(a)), - requested_reviewers: (data.requested_reviewers || []).map((r) => - getLogin(r) - ), - commits: data.commits, - additions: data.additions, - deletions: data.deletions, - changed_files: data.changed_files, - }; + static fromGithubApi(data, signatures, comments, reviews, commitStatuses, labels) { + data = { + repo: data.base.repo.full_name, + number: data.number, + state: data.state, + title: data.title, + body: data.body || '', + draft: data.draft, + created_at: utils.fromDateString(data.created_at), + updated_at: utils.fromDateString(data.updated_at), + closed_at: utils.fromDateString(data.closed_at), + mergeable: data.mergeable, + merged_at: utils.fromDateString(data.merged_at), + difficulty: data.difficulty, + milestone: { + title: data.milestone && data.milestone.title, + due_on: data.milestone && utils.fromDateString(data.milestone.due_on), + }, + head: { + ref: data.head.ref, + sha: data.head.sha, + repo: { + owner: { + login: data.head.repo.owner.login, + }, + name: data.head.repo.name, + }, + }, + base: { + ref: data.base.ref, + }, + user: { + login: getLogin(data.user), + }, + assignees: (data.assignees || []).map(a => getLogin(a)), + requested_reviewers: (data.requested_reviewers || []).map(r => getLogin(r)), + commits: data.commits, + additions: data.additions, + deletions: data.deletions, + changed_files: data.changed_files, + }; - return new Pull(data, signatures, comments, reviews, commitStatuses, labels); - } + return new Pull(data, signatures, comments, reviews, commitStatuses, labels); + } - /** - * Takes an object representing a DB row, and returns an instance of this - * Pull object. - */ - static getFromDB( - data, - signatures, - comments, - reviews, - commitStatuses, - labels - ) { - var pullData = { - repo: data.repo, - number: data.number, - state: data.state, - title: data.title, - body: data.body, - draft: data.draft === 1, - created_at: utils.fromUnixTime(data.date), - updated_at: utils.fromUnixTime(data.date_updated), - closed_at: utils.fromUnixTime(data.date_closed), - mergeable: data.mergeable, - merged_at: utils.fromUnixTime(data.date_merged), - difficulty: data.difficulty, - additions: data.additions, - deletions: data.deletions, - milestone: { - title: data.milestone_title, - due_on: utils.fromUnixTime(data.milestone_due_on), - }, - head: { - ref: data.head_branch, - sha: data.head_sha, - repo: { - owner: { - login: data.repo.split("/")[0], - }, - }, - }, - base: { - ref: data.base_branch, - }, - user: { - login: data.owner, - }, - // mysql2 auto-parses the JSON column, so this is already an array (or null). - assignees: data.assignees ?? [], - requested_reviewers: data.requested_reviewers ?? [], - cr_req: data.cr_req, - qa_req: data.qa_req, - }; + /** + * Takes an object representing a DB row, and returns an instance of this + * Pull object. + */ + static getFromDB(data, signatures, comments, reviews, commitStatuses, labels) { + var pullData = { + repo: data.repo, + number: data.number, + state: data.state, + title: data.title, + body: data.body, + draft: data.draft === 1, + created_at: utils.fromUnixTime(data.date), + updated_at: utils.fromUnixTime(data.date_updated), + closed_at: utils.fromUnixTime(data.date_closed), + mergeable: data.mergeable, + merged_at: utils.fromUnixTime(data.date_merged), + difficulty: data.difficulty, + additions: data.additions, + deletions: data.deletions, + milestone: { + title: data.milestone_title, + due_on: utils.fromUnixTime(data.milestone_due_on), + }, + head: { + ref: data.head_branch, + sha: data.head_sha, + repo: { + owner: { + login: data.repo.split('/')[0], + }, + }, + }, + base: { + ref: data.base_branch, + }, + user: { + login: data.owner, + }, + // mysql2 auto-parses the JSON column, so this is already an array (or null). + assignees: data.assignees ?? [], + requested_reviewers: data.requested_reviewers ?? [], + cr_req: data.cr_req, + qa_req: data.qa_req, + }; - return new Pull( - pullData, - signatures, - comments, - reviews, - commitStatuses, - labels - ); - } + return new Pull(pullData, signatures, comments, reviews, commitStatuses, labels); + } } export default Pull; From ed0b9229cd0aa6b01e3db250517dadd289cb5bc2 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 10:09:17 -0700 Subject: [PATCH 252/381] Claims live on GitHub now; delete the in-memory store A claim was two parallel facts: an entry in lib/claims.js's in-memory Map (broadcast over a reviewClaims socket event, expired by TTL, wiped by every server restart) and a best-effort GitHub review request made alongside it. GitHub's copy is the better one: it survives restarts, it shows on github.com, and GitHub clears it by itself the moment the requested reviewer submits a review. So the in-memory store is gone and the review request IS the claim. - claimReview/releaseReview socket handlers now only write to GitHub (requestReviewers / remove) and trigger an immediate refresh of that pull, so the board updates without waiting for the webhook. The reviewClaims event, the TTL clamp, and the sweep are deleted. - The pull payload gains review_requests: [{login, at, self}], where self marks a claim (the reviewer requested themselves, directly or through pulldasher's bot token) as opposed to an author asking them. Derived from the issue events stream the full refresh already fetches, so this costs zero extra API calls; review_requested webhooks enrich it live with the sender and a timestamp. - The metadata cache is memory-only and never written to the DB (standing constraint); after a restart, at is null until the next full refresh, and clients degrade gracefully. - The bot's own login (to tell its writes apart from real GitHub-UI requests) comes from users.getAuthenticated, fetched once and memoized. test/claims.test.js goes with the store; the new test/derive-review-requests.test.js covers the events derivation (request/removal netting, self vs bot vs author attribution, the no-events fallback). Co-Authored-By: Claude Opus 4.8 --- app.js | 46 ++++------ controllers/githubHooks.js | 44 ++++++++++ lib/claims.js | 122 -------------------------- lib/git-manager.js | 94 +++++++++++++++++++- models/pull.js | 94 +++++++++++++++++++- test/claims.test.js | 123 -------------------------- test/derive-review-requests.test.js | 130 ++++++++++++++++++++++++++++ 7 files changed, 378 insertions(+), 275 deletions(-) delete mode 100644 lib/claims.js delete mode 100644 test/claims.test.js create mode 100644 test/derive-review-requests.test.js diff --git a/app.js b/app.js index 573c00c8..9d5933cf 100644 --- a/app.js +++ b/app.js @@ -6,7 +6,6 @@ import authManager from './lib/authentication.js'; import socketAuthenticator from './lib/socket-auth.js'; import refresh from './lib/refresh.js'; import pullManager from './lib/pull-manager.js'; -import claims, { SWEEP_INTERVAL_MS } from './lib/claims.js'; import git from './lib/git-manager.js'; import dbManager from './lib/db-manager.js'; import pullQueue from './lib/pull-queue.js'; @@ -66,6 +65,12 @@ app.get('/token', mainController.getToken); app.get('/stats-history', statsController.getHistory); app.post('/hooks/main', hooksController.main); +// Warm the bot-login cache (used to tell a pulldasher claim apart from a +// GitHub-UI self-request) before any webhook or socket traffic needs it. +// Memoized in git-manager, so this just avoids the first caller paying for +// the lookup. +git.getBotLogin(); + debug('Loading all recent pulls from the DB'); dbManager .getRecentPulls(pullManager.getOldestAllowedPullTimestamp()) @@ -107,7 +112,6 @@ io.on('connection', function (socket) { if (user) { socket.user = user; socket.emit('authenticated'); - socket.emit('reviewClaims', claims.all()); pullManager.addSocket(socket); } else { socket.emit('unauthenticated'); @@ -119,42 +123,30 @@ io.on('connection', function (socket) { refresh.pull(repo, number); }); - socket.on('claimReview', function (repo, number, ttlMs) { + // GitHub's requested_reviewers is now the only claim state (see + // review_requests on the pull payload). `ttlMs` may still arrive from + // older clients that used to size a claim's expiry; it's meaningless now + // and ignored. Requesting the reviewer on GitHub, then refreshing the pull + // from the API, is what makes the claim show up for every client -- there's + // no separate broadcast to do here. + socket.on('claimReview', function (repo, number) { if (!socket.user) { return; } - claims.claim(repo, number, socket.user.username, Date.now(), ttlMs); - io.emit('reviewClaims', claims.all()); - // Mirror the claim onto the PR itself: put the claimer in GitHub's - // Reviewers list so the claim is visible to anyone not on the dashboard. - // Best-effort and fire-and-forget — the local claim already succeeded, and - // GitHub legitimately refuses this when the claimer authored the PR. - git.requestReviewer(repo, number, socket.user.username); + git.requestReviewer(repo, number, socket.user.username).then(function () { + refresh.pull(repo, number); + }); }); socket.on('releaseReview', function (repo, number) { if (!socket.user) { return; } - if (claims.release(repo, number, socket.user.username)) { - io.emit('reviewClaims', claims.all()); - git.removeReviewer(repo, number, socket.user.username); - } + git.removeReviewer(repo, number, socket.user.username).then(function () { + refresh.pull(repo, number); + }); }); }); -// Sweep expired review claims and re-broadcast the whole map on an interval, -// so a stale claim disappears from every client within one sweep even if -// nobody claims/releases/reads anything in the meantime. unref() so this -// timer never keeps the process (or a test run importing this module) alive -// on its own. -const claimsSweepTimer = setInterval(function () { - claims.prune(); - io.emit('reviewClaims', claims.all()); -}, SWEEP_INTERVAL_MS); -if (typeof claimsSweepTimer.unref === 'function') { - claimsSweepTimer.unref(); -} - debug('Listening on port %s', config.port); httpServer.listen(config.port); diff --git a/controllers/githubHooks.js b/controllers/githubHooks.js index 4be2ec1e..8e1b4a11 100644 --- a/controllers/githubHooks.js +++ b/controllers/githubHooks.js @@ -12,6 +12,7 @@ import queue from '../lib/pull-queue.js'; import getLogin from '../lib/get-user-login.js'; import utils from '../lib/utils.js'; import dbManager from '../lib/db-manager.js'; +import git from '../lib/git-manager.js'; const hooksDebug = debug('pulldasher:hooks'); @@ -73,6 +74,18 @@ const HooksController = { case 'merged': break; + case 'review_requested': + case 'review_request_removed': + // These two actions alone carry `requested_reviewer` (the + // login) and `sender` (who did it) -- fields the generic + // pull_request payload below doesn't have -- so record the + // precise at/self metadata directly, memory-only (no + // review_requests column -- see models/pull.js), before the + // generic `Pull.fromGithubApi(body.pull_request)` call reads + // it back out. + preUpdate = preUpdate.then(() => recordReviewRequestMetadata(body)); + break; + case 'synchronize': // Clear prior CR/QA signoffs immediately for snappy feedback. Emoji // CR/QA stay cleared; a carried-over GitHub *approval* is restored by @@ -267,6 +280,37 @@ function handleLabelEvents(body) { } } +/** + * Record one `review_requested` / `review_request_removed` webhook's + * metadata onto the in-memory review_requests cache (models/pull.js) -- + * memory-only, no DB write. `body.requested_reviewer` is absent for a team + * review request, which Pulldasher doesn't track per-reviewer, so that's a + * no-op. + */ +function recordReviewRequestMetadata(body) { + const reviewer = body.requested_reviewer; + if (!reviewer) { + return Promise.resolve(); + } + + const repo = body.repository.full_name; + const number = body.pull_request.number; + const login = getLogin(reviewer); + + if (body.action === 'review_request_removed') { + Pull.recordReviewRequestRemoved(repo, number, login); + return Promise.resolve(); + } + + return git.getBotLogin().then(function (botLogin) { + const senderLogin = getLogin(body.sender); + Pull.recordReviewRequested(repo, number, login, { + at: Math.floor(Date.now() / 1000), + self: senderLogin === login || (Boolean(botLogin) && senderLogin === botLogin), + }); + }); +} + function refreshPullOrIssue(responseBody) { var repo = responseBody.repository.full_name; diff --git a/lib/claims.js b/lib/claims.js deleted file mode 100644 index 4d479f88..00000000 --- a/lib/claims.js +++ /dev/null @@ -1,122 +0,0 @@ -/** - * In-memory "who's reviewing this PR" review-claim map, keyed `repo#number`. - * - * This is purely social, not a lock: claiming over someone else's claim is - * allowed on purpose (a deliberate correction, not blocked -- last writer - * wins). Nothing here is persisted anywhere; a server restart clears every - * claim, by design. - * - * Each claim carries its own expiry: the claimer's client sends how long the - * claim should last, so one person's 1-hour claim and another's 8-hour claim - * age independently. An absent or out-of-range length falls back to - * DEFAULT_TTL_MS, clamped to [MIN_TTL_MS, MAX_TTL_MS] so a client can't pin a - * claim forever. `expiresAt` stays server-side; the broadcast entry is still - * just `{ login, at }`, so older clients are unaffected. - * - * Entries are pruned lazily (on any read or write) once they're past their own - * expiry, so a stale claim never lingers past the next touch. app.js also runs - * a periodic sweep on SWEEP_INTERVAL_MS so a claim goes away even if nobody - * claims/releases/reads in the meantime. - */ -import debug from './debug.js'; - -const claimsDebug = debug('pulldasher:claims'); - -export const DEFAULT_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours -// kept as TTL_MS for backwards compatibility with existing importers/tests -export const TTL_MS = DEFAULT_TTL_MS; -export const MIN_TTL_MS = 5 * 60 * 1000; // 5 minutes -export const MAX_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours -export const SWEEP_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes - -const claims = new Map(); - -function keyFor(repo, number) { - return `${repo}#${number}`; -} - -// A claim length the caller asked for, defaulted and clamped so a claim always -// has a sane, finite lifetime. -function clampTtl(ttlMs) { - if (typeof ttlMs !== 'number' || !Number.isFinite(ttlMs)) { - return DEFAULT_TTL_MS; - } - return Math.max(MIN_TTL_MS, Math.min(MAX_TTL_MS, ttlMs)); -} - -// The wire/return shape: expiry is a server-internal detail, so callers and the -// broadcast only ever see { login, at }. -function publicEntry(entry) { - return { login: entry.login, at: entry.at }; -} - -// Deletes claims past their own expiry as of `now`. Returns whether anything -// was removed, so callers can tell whether the map actually changed. -function pruneExpired(now) { - let prunedAny = false; - for (const [key, entry] of claims) { - if (now > entry.expiresAt) { - claims.delete(key); - prunedAny = true; - } - } - if (prunedAny) { - claimsDebug('pruned expired claims'); - } - return prunedAny; -} - -export default { - /** - * Claims a review for `login`, replacing any existing claim on this pull - * (last-writer-wins). `now` defaults to Date.now() but can be injected for - * testing. `ttlMs` is the requested claim length; absent/out-of-range falls - * back to DEFAULT_TTL_MS, clamped to [MIN_TTL_MS, MAX_TTL_MS]. Returns the - * public `{ login, at }` entry. - */ - claim(repo, number, login, now = Date.now(), ttlMs) { - pruneExpired(now); - const entry = { login, at: now, expiresAt: now + clampTtl(ttlMs) }; - claims.set(keyFor(repo, number), entry); - claimsDebug('claim %s by %s until %d', keyFor(repo, number), login, entry.expiresAt); - return publicEntry(entry); - }, - - /** - * Releases a review claim, but only if `login` is the one who currently - * holds it. Returns whether the release took effect. - */ - release(repo, number, login, now = Date.now()) { - pruneExpired(now); - const key = keyFor(repo, number); - const entry = claims.get(key); - const owned = Boolean(entry) && entry.login === login; - if (owned) { - claims.delete(key); - claimsDebug('release %s by %s', key, login); - } - return owned; - }, - - /** - * The whole claims map as a plain object: `{ "repo#number": { login, at } }`. - */ - all(now = Date.now()) { - pruneExpired(now); - const out = {}; - for (const [key, entry] of claims) { - out[key] = publicEntry(entry); - } - return out; - }, - - /** - * Removes expired entries without touching a specific claim. Used by the - * periodic sweep in app.js, and exposed so TTL behavior can be tested - * directly by injecting `now` instead of mocking Date. Returns whether - * anything was removed. - */ - prune(now = Date.now()) { - return pruneExpired(now); - }, -}; diff --git a/lib/git-manager.js b/lib/git-manager.js index c17b87cd..7c166fdc 100644 --- a/lib/git-manager.js +++ b/lib/git-manager.js @@ -99,11 +99,37 @@ export function observeRateLimit(pacer) { }); } +// The login pulldasher's own API token authenticates as. Never changes for +// the life of the process, so fetched once via users.getAuthenticated and +// memoized -- every later caller (a full refresh deriving review_requests, a +// review_requested/removed webhook) reuses the same promise. Used to tell a +// pulldasher-initiated claim (requestReviewer/removeReviewer, made with this +// same token) apart from a login's own GitHub-UI self-request: both count as +// `self`, but only the latter has actor === requested reviewer. +let botLoginPromise = null; +export function getBotLogin() { + if (!botLoginPromise) { + botLoginPromise = logErrors( + githubRest.users.getAuthenticated().then(res => getLogin(res.data)), + 'Getting the authenticated (bot) user' + ).catch(err => { + // A single bad lookup shouldn't wedge every later self/bot check -- + // fall back to null, which just makes deriveReviewRequests treat every + // request as not-bot (self only via an actual self-request). + gitDebug('Could not determine bot login: %s', (err && err.message) || err); + return null; + }); + } + return botLoginPromise; +} + const githubRest = github.rest; export default { github: githubRest, + getBotLogin, + /** * Returns a promise which resolves to a GitHub API response to * a query for a particular Pull Request. @@ -265,6 +291,10 @@ export default { // Only so we have the canonical list of labels. var ghIssue = getIssue(repo, githubPull.number); var reviews = getReviews(repo, githubPull.number); + // Resolved (and cached) once per process; threaded through so + // deriveReviewRequests can tell a pulldasher claim apart from a + // GitHub-UI self-request. + var botLogin = getBotLogin(); // Returned to the map function. Each element of githubPulls maps to // a promise that resolves to a Pull. @@ -277,6 +307,7 @@ export default { events, ghIssue, reviews, + botLogin, ]).then(function (results) { var reviewComments = results[0], comments = results[1], @@ -285,7 +316,8 @@ export default { jobRuns = results[4], events = results[5], ghIssue = results[6], - reviews = results[7]; + reviews = results[7], + botLogin = results[8]; // Array of Signature objects. var commentSignatures = comments.reduce(function (sigs, comment) { @@ -389,13 +421,19 @@ export default { // Array of Label objects. const labels = getLabelsFromEvents(events, ghIssue); + // Array of { login, at, self }, one per current requested_reviewers + // entry -- see deriveReviewRequests. + const requestedReviewerLogins = (githubPull.requested_reviewers || []).map(getLogin); + const reviewRequests = deriveReviewRequests(events, requestedReviewerLogins, botLogin); + const pull = Pull.fromGithubApi( githubPull, signatures, comments, reviews, allCommitStatuses, - labels + labels, + reviewRequests ); return pull.syncToIssue(); }); @@ -513,6 +551,58 @@ function getLabelsFromEvents(events, ghIssue) { }); } +/** + * Derive each currently-requested reviewer's `{ login, at, self }` from an + * issue's raw events stream (the same stream getLabelsFromEvents reads via + * issues.listEvents) -- no extra API calls. Walks the review_requested / + * review_request_removed events in order and nets them out to who is + * *currently* requested, each with when the request landed (epoch seconds) + * and whether it was a claim. + * + * `requestedReviewerLogins` (the pull payload's own requested_reviewers) is + * authoritative for WHO is currently requested -- the events only supply + * when/how, and can be incomplete (GitHub caps how far back the events API + * goes). A login in that list with no matching event still gets an entry, + * just `{ at: null, self: false }`, per the wire contract. + * + * `self` is true when the request was made by the reviewer themself (a + * GitHub-UI self-request) or by pulldasher's own bot account (a claim, made + * via requestReviewer with the bot token) -- both read as "this reviewer + * claimed the review" rather than someone else asking them to look at it. + * `botLogin` is whatever getBotLogin() resolved to; null (lookup failed, or + * hasn't resolved yet) just means the bot-request case never matches. + */ +export function deriveReviewRequests(events, requestedReviewerLogins, botLogin) { + const relevant = _.filter( + events, + event => event.event === 'review_requested' || event.event === 'review_request_removed' + ); + const ordered = _.sortBy(relevant, 'created_at'); + + const activeByLogin = new Map(); + ordered.forEach(event => { + // Team review requests have no individual reviewer to attribute this to. + if (!event.requested_reviewer) { + return; + } + const login = getLogin(event.requested_reviewer); + if (event.event === 'review_request_removed') { + activeByLogin.delete(login); + return; + } + const actorLogin = getLogin(event.actor); + activeByLogin.set(login, { + login, + at: Math.floor(utils.toUnixTime(event.created_at)), + self: actorLogin === login || (Boolean(botLogin) && actorLogin === botLogin), + }); + }); + + return requestedReviewerLogins.map( + login => activeByLogin.get(login) || { login, at: null, self: false } + ); +} + /** * Find when the issue's current assignee was assigned, from its events. * diff --git a/models/pull.js b/models/pull.js index dd0fc18a..14045f49 100644 --- a/models/pull.js +++ b/models/pull.js @@ -9,6 +9,41 @@ import getLogin from '../lib/get-user-login.js'; const log = debug('pulldasher:pull'); +/** + * Memory-only cache of review-request metadata: `"repo#number"` -> a Map of + * `login` -> `{ at, self }`. GitHub's requested_reviewers (a plain login + * list, carried on `data` like any other field) is the source of truth for + * WHO is currently requested; this cache only ever adds the *when/how* for + * logins we've been able to observe an origin for, via one of two writers: + * + * - A full refresh (git-manager's parse(), which reads the issue's events + * stream) computes the complete, authoritative set for a pull and + * replaces this cache's entry for it wholesale (see fromGithubApi below). + * - A `review_requested` / `review_request_removed` webhook (see + * controllers/githubHooks.js) knows only its own one login; it updates + * that single entry via recordReviewRequested/recordReviewRequestRemoved. + * + * Reading (getReviewRequests, used by toObject) always reconciles against the + * pull's *current* requested_reviewers at read time, so a login the cache + * doesn't know about yet (e.g. requested before Pulldasher ever saw an event + * for it, or before the next full refresh) still gets an entry, just + * `{ at: null, self: false }` -- never a DB write, and never persisted past a + * process restart. + */ +const reviewRequestsByPull = new Map(); + +function reviewRequestsKey(repo, number) { + return `${repo}#${number}`; +} + +function getReviewRequests(repo, number, requestedReviewerLogins) { + const known = reviewRequestsByPull.get(reviewRequestsKey(repo, number)); + return (requestedReviewerLogins || []).map(login => { + const entry = known && known.get(login); + return entry ? { login, at: entry.at, self: entry.self } : { login, at: null, self: false }; + }); +} + class Pull { constructor(data, signatures, comments, reviews, commitStatuses, labels) { this.data = data; @@ -144,6 +179,7 @@ class Pull { var data = _.extend({}, this.data); data.status = this.getStatus(); data.labels = this.labels.map(label => label.data); + data.review_requests = getReviewRequests(data.repo, data.number, data.requested_reviewers); return data; } @@ -225,7 +261,28 @@ class Pull { return bodyTags; } - static fromGithubApi(data, signatures, comments, reviews, commitStatuses, labels) { + /** + * `reviewRequests`, when passed, is the complete `{ login, at, self }[]` + * git-manager's parse() derived from the issue's events stream (see + * deriveReviewRequests) -- authoritative as of this refresh, so it + * replaces whatever this pull's cache entry held. + * + * The webhook path (controllers/githubHooks.js) has no events and so + * calls this with `reviewRequests` omitted: the cache is left untouched + * here, and toObject()'s read-time reconcile (via getReviewRequests) + * carries forward whatever's already cached for logins still in + * requested_reviewers, defaulting any login it's never seen to + * `{ at: null, self: false }` until the next full refresh. + */ + static fromGithubApi( + data, + signatures, + comments, + reviews, + commitStatuses, + labels, + reviewRequests + ) { data = { repo: data.base.repo.full_name, number: data.number, @@ -267,9 +324,44 @@ class Pull { changed_files: data.changed_files, }; + if (reviewRequests) { + reviewRequestsByPull.set( + reviewRequestsKey(data.repo, data.number), + new Map(reviewRequests.map(r => [r.login, { at: r.at, self: r.self }])) + ); + } + return new Pull(data, signatures, comments, reviews, commitStatuses, labels); } + /** + * Record a single `review_requested` webhook precisely: `login` was + * requested at `at` (epoch seconds), `self` per the wire contract. Merges + * into whatever's already cached for this pull rather than replacing it -- + * unlike the full-refresh path, a webhook only ever knows about its own + * one login. + */ + static recordReviewRequested(repo, number, login, { at, self }) { + const key = reviewRequestsKey(repo, number); + const known = reviewRequestsByPull.get(key) || new Map(); + known.set(login, { at, self }); + reviewRequestsByPull.set(key, known); + } + + /** + * Record a single `review_request_removed` webhook: drop `login`'s cached + * metadata for this pull. toObject() only ever emits entries for logins + * still in requested_reviewers anyway, so this is mostly cache hygiene -- + * it keeps a since-re-requested login from momentarily showing stale + * at/self from its prior request. + */ + static recordReviewRequestRemoved(repo, number, login) { + const known = reviewRequestsByPull.get(reviewRequestsKey(repo, number)); + if (known) { + known.delete(login); + } + } + /** * Takes an object representing a DB row, and returns an instance of this * Pull object. diff --git a/test/claims.test.js b/test/claims.test.js deleted file mode 100644 index 33e5855a..00000000 --- a/test/claims.test.js +++ /dev/null @@ -1,123 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import claims, { TTL_MS, MAX_TTL_MS } from '../lib/claims.js'; - -// lib/claims.js holds one module-level Map, so give every test its own -// repo#number keys (rather than resetting shared state between tests). -const NOW = 1_700_000_000_000; // fixed clock (ms); TTL math is NOW-relative - -test('claim records who claimed a pull and when', () => { - const entry = claims.claim('test/repo', 1, 'alice', NOW); - assert.deepEqual(entry, { login: 'alice', at: NOW }); - assert.deepEqual(claims.all(NOW)['test/repo#1'], { login: 'alice', at: NOW }); -}); - -test("claim is last-writer-wins: claiming over someone else's claim replaces it", () => { - claims.claim('test/repo', 2, 'alice', NOW); - const replaced = claims.claim('test/repo', 2, 'bob', NOW + 1000); - - assert.deepEqual(replaced, { login: 'bob', at: NOW + 1000 }); - assert.deepEqual(claims.all(NOW + 1000)['test/repo#2'], { - login: 'bob', - at: NOW + 1000, - }); -}); - -test('release removes the claim when the releaser owns it', () => { - claims.claim('test/repo', 3, 'alice', NOW); - const released = claims.release('test/repo', 3, 'alice', NOW); - - assert.equal(released, true); - assert.equal(claims.all(NOW)['test/repo#3'], undefined); -}); - -test('release is a no-op when the releaser does not own the claim', () => { - claims.claim('test/repo', 4, 'alice', NOW); - const released = claims.release('test/repo', 4, 'bob', NOW); - - assert.equal(released, false); - assert.deepEqual(claims.all(NOW)['test/repo#4'], { login: 'alice', at: NOW }); -}); - -test('release on a pull with no claim returns false', () => { - const released = claims.release('test/repo', 999, 'alice', NOW); - assert.equal(released, false); -}); - -test('prune removes entries older than TTL_MS as of the given `now`', () => { - claims.claim('test/repo', 5, 'alice', NOW); - - const prunedTooSoon = claims.prune(NOW + TTL_MS - 1); - assert.equal(prunedTooSoon, false); - assert.deepEqual(claims.all(NOW + TTL_MS - 1)['test/repo#5'], { - login: 'alice', - at: NOW, - }); - - const prunedAfterTtl = claims.prune(NOW + TTL_MS + 1); - assert.equal(prunedAfterTtl, true); - assert.equal(claims.all(NOW + TTL_MS + 1)['test/repo#5'], undefined); -}); - -test("a claim's own ttl decides when it expires, independent of others", () => { - const shortTtl = 30 * 60 * 1000; // 30 minutes - claims.claim('ttl/repo', 20, 'alice', NOW, shortTtl); - claims.claim('ttl/repo', 21, 'bob', NOW); // default 4h - - // just before alice's 30m expiry: both still present - assert.deepEqual(claims.all(NOW + shortTtl - 1)['ttl/repo#20'], { - login: 'alice', - at: NOW, - }); - // just after: alice is gone, bob (default ttl) remains - const snapshot = claims.all(NOW + shortTtl + 1); - assert.equal(snapshot['ttl/repo#20'], undefined); - assert.deepEqual(snapshot['ttl/repo#21'], { login: 'bob', at: NOW }); -}); - -test('an out-of-range ttl is clamped to MAX_TTL_MS, not honored as-is', () => { - claims.claim('ttl/repo', 22, 'alice', NOW, 10 * 365 * 24 * 60 * 60 * 1000); // 10y - // still alive past the default 4h (clamp didn't drop it to the default)... - assert.deepEqual(claims.all(NOW + TTL_MS + 1)['ttl/repo#22'], { login: 'alice', at: NOW }); - // ...but gone once the 24h clamp elapses, never the requested 10 years - assert.equal(claims.all(NOW + MAX_TTL_MS + 1)['ttl/repo#22'], undefined); -}); - -test('an absent ttl falls back to the default 4h', () => { - claims.claim('ttl/repo', 24, 'dave', NOW); // no ttl - assert.deepEqual(claims.all(NOW + TTL_MS - 1)['ttl/repo#24'], { login: 'dave', at: NOW }); - assert.equal(claims.all(NOW + TTL_MS + 1)['ttl/repo#24'], undefined); -}); - -test('the broadcast entry never leaks expiresAt', () => { - claims.claim('ttl/repo', 23, 'carol', NOW, 60 * 60 * 1000); - assert.deepEqual(Object.keys(claims.all(NOW)['ttl/repo#23']).sort(), ['at', 'login']); -}); - -test('all() lazily prunes expired entries as a side effect of reading', () => { - claims.claim('test/repo', 6, 'alice', NOW); - - const snapshot = claims.all(NOW + TTL_MS + 1); - - assert.equal(snapshot['test/repo#6'], undefined); -}); - -test('all() returns a plain object shaped { "repo#number": { login, at } }', () => { - claims.claim('shape/repo', 7, 'carol', NOW); - - const snapshot = claims.all(NOW); - - assert.equal(Object.getPrototypeOf(snapshot), Object.prototype); - assert.deepEqual(snapshot['shape/repo#7'], { login: 'carol', at: NOW }); -}); - -test('claim and release only affect their own repo#number key', () => { - claims.claim('multi/repo', 10, 'alice', NOW); - claims.claim('multi/repo', 11, 'bob', NOW); - - claims.release('multi/repo', 10, 'alice', NOW); - - const snapshot = claims.all(NOW); - assert.equal(snapshot['multi/repo#10'], undefined); - assert.deepEqual(snapshot['multi/repo#11'], { login: 'bob', at: NOW }); -}); diff --git a/test/derive-review-requests.test.js b/test/derive-review-requests.test.js new file mode 100644 index 00000000..aef453ce --- /dev/null +++ b/test/derive-review-requests.test.js @@ -0,0 +1,130 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { deriveReviewRequests } from '../lib/git-manager.js'; + +// Minimal stand-in for a github issues.listEvents entry. `requested_reviewer` +// is the shape used for an individual reviewer (a team request has +// `requested_team` instead, and is filtered out -- not exercised here since +// Pulldasher doesn't track per-team requests). +function requestedEvent({ at, actor, reviewer }) { + return { + event: 'review_requested', + created_at: at, + actor: { login: actor }, + requested_reviewer: { login: reviewer }, + }; +} + +function removedEvent({ at, actor, reviewer }) { + return { + event: 'review_request_removed', + created_at: at, + actor: { login: actor }, + requested_reviewer: { login: reviewer }, + }; +} + +test('a request followed by its removal nets to nothing', () => { + const events = [ + requestedEvent({ at: '2024-01-01T00:00:00Z', actor: 'alice', reviewer: 'bob' }), + removedEvent({ at: '2024-01-01T01:00:00Z', actor: 'alice', reviewer: 'bob' }), + ]; + + const result = deriveReviewRequests(events, [], 'pulldasher-bot'); + + assert.deepEqual(result, []); +}); + +test('a request followed by its removal nets to nothing even when requested_reviewers still lists it stale', () => { + // requestedReviewerLogins is authoritative for WHO -- if the payload still + // (incorrectly) lists bob, the removed event doesn't erase him from the + // output, it just leaves him with no matching event. + const events = [ + requestedEvent({ at: '2024-01-01T00:00:00Z', actor: 'alice', reviewer: 'bob' }), + removedEvent({ at: '2024-01-01T01:00:00Z', actor: 'alice', reviewer: 'bob' }), + ]; + + const result = deriveReviewRequests(events, ['bob'], 'pulldasher-bot'); + + assert.deepEqual(result, [{ login: 'bob', at: null, self: false }]); +}); + +test('self is true for a GitHub-UI self-request (actor === reviewer)', () => { + const events = [ + requestedEvent({ at: '2024-01-01T00:00:00Z', actor: 'carol', reviewer: 'carol' }), + ]; + + const result = deriveReviewRequests(events, ['carol'], 'pulldasher-bot'); + + assert.deepEqual(result, [ + { login: 'carol', at: Math.floor(Date.parse('2024-01-01T00:00:00Z') / 1000), self: true }, + ]); +}); + +test("self is true for a pulldasher (bot-token) claim on someone else's behalf", () => { + const events = [ + requestedEvent({ at: '2024-01-01T00:00:00Z', actor: 'pulldasher-bot', reviewer: 'dave' }), + ]; + + const result = deriveReviewRequests(events, ['dave'], 'pulldasher-bot'); + + assert.deepEqual(result, [ + { login: 'dave', at: Math.floor(Date.parse('2024-01-01T00:00:00Z') / 1000), self: true }, + ]); +}); + +test('self is false when a third party (e.g. the PR author) requests someone else', () => { + const events = [ + requestedEvent({ at: '2024-01-01T00:00:00Z', actor: 'author', reviewer: 'erin' }), + ]; + + const result = deriveReviewRequests(events, ['erin'], 'pulldasher-bot'); + + assert.deepEqual(result, [ + { login: 'erin', at: Math.floor(Date.parse('2024-01-01T00:00:00Z') / 1000), self: false }, + ]); +}); + +test('a reviewer in requested_reviewers with no matching event gets at: null, self: false', () => { + const result = deriveReviewRequests([], ['frank'], 'pulldasher-bot'); + + assert.deepEqual(result, [{ login: 'frank', at: null, self: false }]); +}); + +test('a null botLogin (lookup never resolved) just means the bot case never matches', () => { + const events = [ + requestedEvent({ at: '2024-01-01T00:00:00Z', actor: 'pulldasher-bot', reviewer: 'gina' }), + ]; + + const result = deriveReviewRequests(events, ['gina'], null); + + assert.deepEqual(result, [ + { login: 'gina', at: Math.floor(Date.parse('2024-01-01T00:00:00Z') / 1000), self: false }, + ]); +}); + +test('out-of-order events are still netted correctly (sorted by created_at first)', () => { + const events = [ + removedEvent({ at: '2024-01-02T00:00:00Z', actor: 'alice', reviewer: 'holly' }), + requestedEvent({ at: '2024-01-01T00:00:00Z', actor: 'alice', reviewer: 'holly' }), + ]; + + const result = deriveReviewRequests(events, [], 'pulldasher-bot'); + + assert.deepEqual(result, []); +}); + +test('multiple reviewers are derived independently, preserving requestedReviewerLogins order', () => { + const events = [ + requestedEvent({ at: '2024-01-01T00:00:00Z', actor: 'ivy', reviewer: 'ivy' }), + requestedEvent({ at: '2024-01-01T01:00:00Z', actor: 'author', reviewer: 'jack' }), + ]; + + const result = deriveReviewRequests(events, ['jack', 'ivy', 'kim'], 'pulldasher-bot'); + + assert.deepEqual(result, [ + { login: 'jack', at: Math.floor(Date.parse('2024-01-01T01:00:00Z') / 1000), self: false }, + { login: 'ivy', at: Math.floor(Date.parse('2024-01-01T00:00:00Z') / 1000), self: true }, + { login: 'kim', at: null, self: false }, + ]); +}); From fa2b87ac9a084b6452d8b38fb516630bed0da08a Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 10:10:17 -0700 Subject: [PATCH 253/381] Read claims from the pull itself; drop the claims map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client half of GitHub-as-source-of-truth: a claim is now the self-made review request riding on the pull's own payload (review_requests: [{login, at, self}]), so the socket-pushed claims map, its reviewClaims listener, and the RowOptions.claims threading through every component are gone. claimFor(pull) reads the wire directly; a row re-renders with its claim because the pull reference changes, which also simplifies Row's memo comparator. Semantics that changed with the storage: - requestedReviewers/reviewRequestedFrom now exclude self-requests when metadata is present: a claim is not "GitHub asked you". hasReviewRequest deliberately still counts claims, so the turn rotation defers to any request either way. - Claim.at can be null (server restarted since the request; metadata not yet re-derived). Notes drop the "· X ago" and staleness is treated as unprovable rather than guessed. - Claims no longer expire on a timer, so the claim-length setting is gone; the claim nag stays, skipping unknown-age claims. The fix also caught the nag comparing seconds to milliseconds; it now fires at the configured threshold instead of never. - The Legend's claims section tells the real story: claiming adds you as a requested reviewer on GitHub through pulldasher's bot, clears when you submit your review, release it, or anyone removes you on GitHub, and survives restarts because GitHub holds it. - The dummy backend mutates its pulls' requested_reviewers and re-emits pullChange, so the offline board exercises the same round-trip shape as production. Tests updated in lockstep (claim fixtures now ride the pull payload; null-at cases added): 344 pass. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 12 +--- frontend-v2/src/backend/socket.ts | 76 +++++++++++---------- frontend-v2/src/components/Legend.tsx | 22 +++--- frontend-v2/src/components/Row.tsx | 26 +++---- frontend-v2/src/components/Settings.tsx | 18 +---- frontend-v2/src/components/StatePopover.tsx | 14 ++-- frontend-v2/src/components/WordGroups.tsx | 13 ++-- frontend-v2/src/model/actions.test.ts | 40 ++++++++--- frontend-v2/src/model/actions.ts | 30 +++++--- frontend-v2/src/model/cheers.test.ts | 10 +-- frontend-v2/src/model/cheers.ts | 39 +++++++---- frontend-v2/src/model/deal.test.ts | 17 +++-- frontend-v2/src/model/deal.ts | 15 ++-- frontend-v2/src/model/reviewers.ts | 34 +++++++-- frontend-v2/src/notifications.ts | 13 +++- frontend-v2/src/settings.ts | 7 +- frontend-v2/src/store.ts | 39 +++++------ frontend-v2/src/toasts.tsx | 4 +- frontend-v2/src/types.ts | 9 +++ frontend-v2/src/views/MyWork.tsx | 3 +- frontend-v2/src/views/Review.tsx | 21 ++---- 21 files changed, 249 insertions(+), 213 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index d777d82c..4052936b 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -228,10 +228,9 @@ export function App() { acked, snoozed, refreshProgress, - claims, } = usePulldasher(); // desktop notifications watch the whole board, not the current filter - useNotifications(pulls, me, claims, initialized); + useNotifications(pulls, me, initialized); const [scope, setScope] = useScope(); // a v1 bookmark (?repo=…&author=…&cryo=1…) opens Classic configured the // same way; the chip below shows what it applied and dismisses it @@ -681,10 +680,7 @@ export function App() { // the age baseline is RELATIVE: the board's longest-open pull sets the // full track, everything else is a fraction of it — "how long has this // waited, relative to what waiting looks like here" - const maxAgeDays = useMemo( - () => pulls.reduce((m, p) => Math.max(m, p.ageDays), 1), - [pulls] - ); + const maxAgeDays = useMemo(() => pulls.reduce((m, p) => Math.max(m, p.ageDays), 1), [pulls]); // stable identity so memo(Row) can skip untouched rows on socket bursts const rowOpts: RowOptions = useMemo( () => ({ @@ -699,7 +695,6 @@ export function App() { compact: settings.density === 'compact', laneCap: settings.laneCap, parentOf, - claims, pools, turns, }), @@ -715,7 +710,6 @@ export function App() { settings.density, settings.laneCap, parentOf, - claims, pools, turns, ] @@ -823,7 +817,7 @@ export function App() { history: toastHistory, clearHistory, dismissHistoryItem, - } = useToasts(pulls, me, claims, shippedExtras, closed, onQuickWins, onClaimTurn, initialized); + } = useToasts(pulls, me, shippedExtras, closed, onQuickWins, onClaimTurn, initialized); return ( <> diff --git a/frontend-v2/src/backend/socket.ts b/frontend-v2/src/backend/socket.ts index 921292af..6e5c7651 100644 --- a/frontend-v2/src/backend/socket.ts +++ b/frontend-v2/src/backend/socket.ts @@ -4,9 +4,6 @@ import { isDummy, loadDummy, dummyUser } from './dummy'; export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'; -/** login → { login, at } for the pull that logged the claim; at is ms epoch. */ -export type ReviewClaims = Record; - export interface Backend { /** Resolves once we know who the viewer is (the /token user). */ whoami: () => Promise; @@ -14,15 +11,12 @@ export interface Backend { onPulls: (handler: (payload: InitializePayload | PullData) => void) => void; onConnection: (handler: (state: ConnectionState) => void) => () => void; refreshPull: (repo: string, number: number) => void; - /** The whole claims map, resent on connect and on every change — the - * server owns expiry (4h) and last-writer-wins, so the client never - * reconciles a diff, just replaces its copy. */ - onReviewClaims: (handler: (claims: ReviewClaims) => void) => void; /** Ask to review a pull. The server derives the login from the socket's - * own auth — no login argument here. ttlMs is how long the claim should - * last before the server expires it; the server clamps it to [5min, 24h] - * and defaults to 4h if omitted. */ - claimReview: (repo: string, number: number, ttlMs?: number) => void; + * own auth — no login argument here. A claim is a GitHub review request + * the reviewer made on themselves (see types.ts's review_requests), so it + * has no client-side TTL: it clears when the review is submitted, + * released, or removed on GitHub, same as any other review request. */ + claimReview: (repo: string, number: number) => void; releaseReview: (repo: string, number: number) => void; } @@ -86,11 +80,8 @@ function liveBackend(): Backend { refreshPull(repo, number) { getSocket().emit('refresh', repo, number); }, - onReviewClaims(handler) { - getSocket().on('reviewClaims', (claims: ReviewClaims) => handler(claims)); - }, - claimReview(repo, number, ttlMs) { - getSocket().emit('claimReview', repo, number, ttlMs); + claimReview(repo, number) { + getSocket().emit('claimReview', repo, number); }, releaseReview(repo, number) { getSocket().emit('releaseReview', repo, number); @@ -102,12 +93,18 @@ function dummyBackend(): Backend { let emit: ((payload: InitializePayload | PullData) => void) | null = null; let loaded: InitializePayload | null = null; let staggered = 0; - // The dummy stand-in for the server's claims map: mutated locally by - // claimReview/releaseReview and re-emitted, same shape a real socket - // 'reviewClaims' broadcast would carry. - const claims: ReviewClaims = {}; - let onClaims: ((claims: ReviewClaims) => void) | null = null; - const publishClaims = () => onClaims?.({ ...claims }); + + /** Mutate one dummy pull in place and re-emit it through the normal + * pullChange path — the dummy board's stand-in for a server round trip + * (a claim/release now travels as a pull update, not a separate map). */ + function updatePull(repo: string, number: number, fn: (pull: PullData) => PullData) { + if (!loaded) return; + const idx = loaded.pulls.findIndex(p => p.repo === repo && p.number === number); + if (idx === -1) return; + const next = fn(loaded.pulls[idx]); + loaded.pulls[idx] = next; + emit?.(next); + } return { whoami: () => Promise.resolve(dummyUser()), @@ -130,11 +127,13 @@ function dummyBackend(): Backend { p.status.allCR.filter(s => s.data.active).length < p.status.cr_req ); if (!target) return; - claims[`${target.repo}#${target.number}`] = { - login: 'dummy-teammate', - at: Date.now(), - }; - publishClaims(); + updatePull(target.repo, target.number, pull => ({ + ...pull, + review_requests: [ + ...(pull.review_requests ?? []), + { login: 'dummy-teammate', at: Math.floor(Date.now() / 1000), self: true }, + ], + })); }, 500); }); }, @@ -157,17 +156,22 @@ function dummyBackend(): Backend { 300 + staggered * 15 ); }, - onReviewClaims(handler) { - onClaims = handler; - publishClaims(); - }, - claimReview(repo, number, _ttlMs) { - claims[`${repo}#${number}`] = { login: dummyUser(), at: Date.now() }; - publishClaims(); + claimReview(repo, number) { + updatePull(repo, number, pull => ({ + ...pull, + requested_reviewers: [...new Set([...(pull.requested_reviewers ?? []), dummyUser()])], + review_requests: [ + ...(pull.review_requests ?? []).filter(r => r.login !== dummyUser()), + { login: dummyUser(), at: Math.floor(Date.now() / 1000), self: true }, + ], + })); }, releaseReview(repo, number) { - delete claims[`${repo}#${number}`]; - publishClaims(); + updatePull(repo, number, pull => ({ + ...pull, + requested_reviewers: (pull.requested_reviewers ?? []).filter(l => l !== dummyUser()), + review_requests: (pull.review_requests ?? []).filter(r => r.login !== dummyUser()), + })); }, }; } diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index c6eba4cb..74b97068 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -221,7 +221,7 @@ export function Legend() { ✋ } - def="claim a review so teammates know you’re on it; the hand stays lit on your row, and claimed PRs collect in “You’re reviewing”. It nudges you when it goes stale and clears itself when it expires, both set in Settings (defaults: 2h and 4h)" + def="claim a review so teammates know you’re on it; the hand stays lit on your row, and claimed PRs collect in “You’re reviewing”. It nudges you if it sits too long (set in Settings) and clears once you submit, release it, or GitHub removes you as a reviewer" /> X is reading it} @@ -236,19 +236,21 @@ export function Legend() {

- Claims live in the server’s memory, never the database. Claiming sends a - message over the live websocket; the server records it and broadcasts the - updated list to everyone on the board, so others see “X is reading it” at once. + Claiming a review adds you as a requested reviewer on the PR, through + pulldasher’s own bot account. It’s the same kind of GitHub review request as + anyone else asking you to review, so everyone sees it: on the board, as your + row’s raised hand, and on the PR itself on GitHub.

- Because it’s only in memory, a server restart clears every claim, and nothing - you claim is ever written to disk. + It clears the same way any GitHub review request does: you submit your review, + you release it from Pulldasher, or anyone removes you as a reviewer on GitHub. + There’s no timer counting it down.

- Claims expire on their own: a claim goes stale when your warning time passes - (it stops holding others off, and you’re nudged to finish or release it), and - is dropped entirely when its length runs out. Both are set in Settings, - defaulting to 2h and 4h. Releasing clears it for everyone right away. + Because GitHub holds the claim, not the server, it survives a server restart. + The one thing that doesn’t always survive is the “X ago” timestamp: right after + a restart the server hasn’t caught up on exactly when the request was made, so + a claim can briefly show with no age attached.

diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 720a3dda..1691c873 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -1,7 +1,7 @@ import { memo, useState, type ReactNode } from 'react'; import type { DerivedPull } from '../model/status'; import { isIterating, lastPushEpoch } from '../model/status'; -import { rowNote } from '../model/actions'; +import { type Claim, rowNote } from '../model/actions'; import { matchedRegions } from '../model/regions'; import type { ParentRef } from '../model/stack'; import { ago, epoch, pullKey, rowDomId, shortRepo } from '../format'; @@ -64,9 +64,6 @@ export interface RowOptions { * lens' scope), so the 'stacked' flag can name it instead of just saying * "based on ". */ parentOf?: (p: DerivedPull) => ParentRef | null; - /** pull key → who's claimed to review it (store.ts's Snapshot.claims): - * absolves/redirects the row's note ahead of the turn rotation below. */ - claims?: Readonly>; /** per-repo reviewer pool for the deterministic turn rotation * (model/rotation.ts), memoized once in app.tsx over the whole board. */ pools?: ReadonlyMap; @@ -320,7 +317,7 @@ function RowActions({ pull: DerivedPull; overlay?: boolean; me: string; - claim: { login: string; at: number } | null; + claim: Claim | null; }) { const a = useRowActions(pull); const btn = @@ -433,13 +430,7 @@ function StarGlyph({ on }: { on: boolean }) { * person star/mute in Settings' repo manager and the People lens — so on a * pointer device the kebab was pure redundancy. */ -function RowActionsKebab({ - pull, - claim, -}: { - pull: DerivedPull; - claim: { login: string; at: number } | null; -}) { +function RowActionsKebab({ pull, claim }: { pull: DerivedPull; claim: Claim | null }) { const a = useRowActions(pull); const settings = useSettings(); const { me } = usePulldasher(); @@ -648,7 +639,7 @@ function MetricRail({ }: { pull: DerivedPull; opts: RowOptions; - claim: { login: string; at: number } | null; + claim: Claim | null; }) { const d = pull.data; const me = opts.me; @@ -711,7 +702,7 @@ function RowImpl({ // who's claimed to review this pull, and whose turn the rotation names — // both optional, so a lens that hasn't threaded them (yet) just sees null // and rowNote falls back to its base (pre-coordination) note - const claim = claimFor(d, opts.claims ?? {}); + const claim = claimFor(d); const turn = opts.turns?.get(key) ?? null; const poolSize = opts.pools?.get(d.repo)?.length ?? 0; // the viewer-relative note (model/actions.ts): never both-null for an open @@ -822,7 +813,11 @@ function RowImpl({ /** * Rows re-render only when their pull is re-derived (the store caches * derive() per PullData reference) or an option actually changes — a burst - * of pullChange events must not reconcile 180 untouched rows. + * of pullChange events must not reconcile 180 untouched rows. There's no + * separate opts.claims to check any more: a claim lives on the pull itself + * (review_requests), so claiming/releasing arrives as a new pull reference + * (a.pull !== b.pull) and the row re-renders through that, same as any other + * pull update. */ export const Row = memo( RowImpl, @@ -839,7 +834,6 @@ export const Row = memo( a.opts.ageRotDays === b.opts.ageRotDays && a.opts.onWeightToggle === b.opts.onWeightToggle && a.opts.parentOf === b.opts.parentOf && - a.opts.claims === b.opts.claims && a.opts.pools === b.opts.pools && a.opts.turns === b.opts.turns ); diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 1930c52d..fd2630e5 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -438,25 +438,9 @@ export function Settings({ onChange={ageRotDays => set({ ageRotDays })} /> - - set({ claimLengthMins: Number(v) })} - /> - - claimed by {claim.login} ·{' '} - {ago(claim.at / 1000)} ago + claimed by {claim.login} + {claim.at != null && <> · {ago(claim.at)} ago}

) : ( turn && ( @@ -229,7 +229,7 @@ function StatePopoverBody({ }: { pull: DerivedPull; me: string; - claim?: { login: string; at: number } | null; + claim?: Claim | null; turn?: string | null; poolSize?: number; whyHere?: string | null; @@ -286,7 +286,7 @@ export function StatePopover({ }: { pull: DerivedPull; me: string; - claim?: { login: string; at: number } | null; + claim?: Claim | null; turn?: string | null; poolSize?: number; /** a ranked lane's one-line reason this pull sits where it does */ diff --git a/frontend-v2/src/components/WordGroups.tsx b/frontend-v2/src/components/WordGroups.tsx index 71dbbff1..45c4e7f4 100644 --- a/frontend-v2/src/components/WordGroups.tsx +++ b/frontend-v2/src/components/WordGroups.tsx @@ -41,7 +41,8 @@ const WORD_GLOSS: Record = { QA: 'Open PRs you could test.', 'Finish draft': 'Your draft. Not up for review until you open it.', // wait-words: why the pull sits - 'waiting on re-CR': 'A reviewer approved it, then new commits landed. Waiting on them to approve again.', + 'waiting on re-CR': + 'A reviewer approved it, then new commits landed. Waiting on them to approve again.', 'waiting on CR': 'Waiting for someone to code review it.', 'with author': 'Changes were requested; the next push is the author’s.', 'waiting on re-QA': 'Someone tested it, then new commits landed. Waiting on them to test again.', @@ -133,16 +134,12 @@ export interface WordGroup { * urgent DO_WORD_RANK first), then wait-groups (WAIT_WORD_RANK), unknown * words last, ties kept in first-seen order (Array#sort is stable). */ -export function groupNodesByWord( - tree: StackedPull[], - me: string, - claims: Readonly> -): WordGroup[] { +export function groupNodesByWord(tree: StackedPull[], me: string): WordGroup[] { const buckets = new Map(); let currentKey: string | null = null; for (const node of tree) { if (node.depth === 0) { - const rw = rowWord(node.pull, me, { claim: claimFor(node.pull.data, claims) }); + const rw = rowWord(node.pull, me, { claim: claimFor(node.pull.data) }); currentKey = `${rw.kind}:${rw.word}`; if (!buckets.has(currentKey)) { buckets.set(currentKey, { word: rw.word, kind: rw.kind, nodes: [] }); @@ -182,7 +179,7 @@ export function WordGroupRows({ cap: number; }) { const tree = groupIntoTree(pulls); - const groups = groupNodesByWord(tree, opts.me, opts.claims ?? {}); + const groups = groupNodesByWord(tree, opts.me); if (!groups.length) return null; const children: ReactNode[] = groups.flatMap(g => [ { dp({ author: 'auth', status: 'needs_cr', starved: true, ageDays: 29, ...o }); it('a claim by me: "Finish your review", with when I claimed it', () => { - const at = Date.now() - 5 * 60_000; // 5 minutes ago, in ms (the wire unit) + const at = Date.now() / 1000 - 5 * 60; // 5 minutes ago, in epoch secs (the wire unit) const note = rowNote(p(), 'me', { claim: { login: 'me', at } }); expect(note.action).toBe('Finish your review'); - expect(note.context).toBe(`you claimed it · ${ago(at / 1000)} ago`); + expect(note.context).toBe(`you claimed it · ${ago(at)} ago`); + }); + + it('a claim by me with no timestamp: "you claimed it", no age', () => { + const note = rowNote(p(), 'me', { claim: { login: 'me', at: null } }); + expect(note.action).toBe('Finish your review'); + expect(note.context).toBe('you claimed it'); }); it('a fresh claim by someone else absolves me: no action, who is reading it', () => { - const at = Date.now() - 10 * 60_000; // well under the 2h stale window + const at = Date.now() / 1000 - 10 * 60; // well under the 2h stale window const note = rowNote(p(), 'me', { claim: { login: 'alice', at } }); expect(note.action).toBeNull(); expect(note.context).toBe('alice is reading it'); }); + it('a claim by someone else with no timestamp: treated as fresh, absolves me', () => { + const note = rowNote(p(), 'me', { claim: { login: 'alice', at: null } }); + expect(note.action).toBeNull(); + expect(note.context).toBe('alice is reading it'); + }); + it('a stale claim (>2h) stops absolving: "Review it" comes back', () => { - const at = Date.now() - 3 * 3600_000; // 3h ago + const at = Date.now() / 1000 - 3 * 3600; // 3h ago const note = rowNote(p(), 'me', { claim: { login: 'alice', at } }); expect(note.action).toBe('Review it'); - expect(note.context).toBe(`alice claimed it ${ago(at / 1000)} ago, pick it up?`); + expect(note.context).toBe(`alice claimed it ${ago(at)} ago, pick it up?`); }); it('claim beats turn — a claim by someone else wins even when it is also my turn', () => { - const at = Date.now() - 10 * 60_000; + const at = Date.now() / 1000 - 10 * 60; const note = rowNote(p(), 'me', { claim: { login: 'alice', at }, turn: 'me' }); expect(note.action).toBeNull(); expect(note.context).toBe('alice is reading it'); @@ -683,14 +695,14 @@ describe('rowNote — claim/turn coordination (reviewer path only)', () => { }); it('needs_recr: a claim by me still reads "Finish your review"', () => { - const at = Date.now() - 60_000; + const at = Date.now() / 1000 - 60; const note = rowNote( dp({ author: 'auth', status: 'needs_recr', starved: true, recrBy: ['someone-else'] }), 'me', { claim: { login: 'me', at } } ); expect(note.action).toBe('Finish your review'); - expect(note.context).toBe(`you claimed it · ${ago(at / 1000)} ago`); + expect(note.context).toBe(`you claimed it · ${ago(at)} ago`); }); it('the author path is unaffected by claim/turn extras', () => { @@ -798,14 +810,22 @@ describe('rowWord — the one-word section-header key', () => { }); it('reviewer sees a fresh claim by someone else → claimed', () => { - const at = Date.now(); + const at = Date.now() / 1000; expect( word({ author: 'auth', status: 'needs_cr' }, 'me', { claim: { login: 'bob', at } }) ).toEqual({ kind: 'wait', word: 'claimed' }); }); + it('a claim with no timestamp reads claimed — can’t prove it’s stale', () => { + expect( + word({ author: 'auth', status: 'needs_cr' }, 'me', { + claim: { login: 'bob', at: null }, + }) + ).toEqual({ kind: 'wait', word: 'claimed' }); + }); + it('a stale claim does not read claimed — it falls back to the "Review it" do', () => { - const at = Date.now() - 3 * 3600_000; // 3h ago, past STALE_CLAIM_SECS + const at = Date.now() / 1000 - 3 * 3600; // 3h ago, past STALE_CLAIM_SECS expect( word({ author: 'auth', status: 'needs_cr' }, 'me', { claim: { login: 'bob', at } }) ).toEqual({ kind: 'do', word: 'Review' }); diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index 65c20ae9..62b114e2 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -182,11 +182,14 @@ function feedbackAnswered(p: DerivedPull): boolean { return at != null && p.headPushedAt != null && p.headPushedAt > at; } -/** A reviewer's claim on a pull: who, and when (ms epoch — see - * backend/socket.ts's ReviewClaims). */ +/** A reviewer's claim on a pull: who, and when (epoch seconds — see + * types.ts's review_requests). `at` is null when the server can't say yet + * (e.g. it restarted before the webhook backfilled the timestamp); a claim + * with no `at` is treated as fresh everywhere below, since there's nothing to + * measure staleness against. */ export interface Claim { login: string; - at: number; + at: number | null; } /** A claim older than this no longer absolves anyone else — the reader may @@ -200,7 +203,9 @@ const STALE_CLAIM_SECS = 2 * 3600; * actively reading it is a stronger signal than the rotation's guess, and a * fresh claim by someone else absolves the viewer entirely (action null) — * but a claim past STALE_CLAIM_SECS stops absolving, since the reader may - * have moved on, and the pull's "Review it" action comes back. + * have moved on, and the pull's "Review it" action comes back. A claim with + * no timestamp can't be proven stale, so it stays fresh (never degrades to + * "pick it up?"). */ function withCoordination( base: RowNote, @@ -210,14 +215,17 @@ function withCoordination( requestedFromMe?: boolean ): RowNote { if (claim) { - const claimAgo = ago(claim.at / 1000); + const claimAgo = claim.at != null ? `${ago(claim.at)} ago` : null; if (claim.login === me) - return { action: 'Finish your review', context: `you claimed it · ${claimAgo} ago` }; - const staleSecs = Date.now() / 1000 - claim.at / 1000; + return { + action: 'Finish your review', + context: claimAgo ? `you claimed it · ${claimAgo}` : 'you claimed it', + }; + const staleSecs = claim.at != null ? Date.now() / 1000 - claim.at : 0; if (staleSecs > STALE_CLAIM_SECS) return { action: 'Review it', - context: `${claim.login} claimed it ${claimAgo} ago, pick it up?`, + context: `${claim.login} claimed it ${claimAgo}, pick it up?`, }; return { action: null, context: `${claim.login} is reading it` }; } @@ -427,10 +435,12 @@ const DO_WORD: Record = { /** Same freshness rule withCoordination uses for a claim by someone else — but * here it decides a single word (claimed vs. still-owed), not the fuller - * action/context pair, so it's reimplemented rather than shared. */ + * action/context pair, so it's reimplemented rather than shared. A claim with + * no timestamp can't be proven stale, so it reads as fresh. */ function freshOtherClaim(me: string, claim?: Claim | null): boolean { if (!claim || claim.login === me) return false; - return Date.now() / 1000 - claim.at / 1000 <= STALE_CLAIM_SECS; + if (claim.at == null) return true; + return Date.now() / 1000 - claim.at <= STALE_CLAIM_SECS; } /** diff --git a/frontend-v2/src/model/cheers.test.ts b/frontend-v2/src/model/cheers.test.ts index 71225be2..5f7edf94 100644 --- a/frontend-v2/src/model/cheers.test.ts +++ b/frontend-v2/src/model/cheers.test.ts @@ -695,19 +695,13 @@ describe('evaluateCheers — loading guard', () => { it('no-ops and carries the baseline while the board is still loading', () => { // the empty snapshot the store publishes before the first payload lands - const { toasts, next } = evaluateCheers( - { pulls: [], me: 'me', claims: {}, ready: false }, - primedBase - ); + const { toasts, next } = evaluateCheers({ pulls: [], me: 'me', ready: false }, primedBase); expect(toasts).toEqual([]); expect(next).toBe(primedBase); }); it('WOULD fire phantom clears against that empty board once ready — the bug the guard prevents', () => { - const { toasts } = evaluateCheers( - { pulls: [], me: 'me', claims: {}, ready: true }, - primedBase - ); + const { toasts } = evaluateCheers({ pulls: [], me: 'me', ready: true }, primedBase); const keys = toasts.map(t => t.dedupeKey); expect(keys).toContain('inbox:zero'); expect(keys).toContain('board:clear'); diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index f2770259..47ef059c 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -176,7 +176,6 @@ export interface CheerInput { * open board. */ closed?: PullData[]; me: string; - claims: Readonly>; /** current time (ms epoch), for aging claims into a stale-claim nag — * injected so the model stays pure and testable; the hook passes Date.now() */ now?: number; @@ -244,6 +243,16 @@ function hasStamp(p: DerivedPull, login: string): boolean { return p.crBy.includes(login) || p.qaBy.includes(login); } +/** Whoever's claimed this pull, read straight off the wire + * (pull.review_requests) — a self-requested review. Mirrors store.ts's + * claimFor; duplicated rather than imported so this stays a pure model + * function independent of the (browser-coupled) store module, like every + * other file in model/. `at` is epoch seconds, null when the server can't say. */ +function claimOf(p: DerivedPull): { login: string; at: number | null } | null { + const entry = (p.data.review_requests ?? []).find(r => r.self && r.login !== p.data.user.login); + return entry ? { login: entry.login, at: entry.at } : null; +} + /** * The one-line "why this pull" for start-here, in priority order: returning a * favor beats a quick win beats plain urgency. `pulls` is the whole board (not @@ -343,7 +352,7 @@ export interface Signals { } export function readSignals(input: CheerInput): Signals { - const { pulls, me, claims } = input; + const { pulls, me } = input; const closed = input.closed ?? []; const now = input.now ?? Date.now(); const pools = buildReviewerPools(pulls); @@ -367,12 +376,10 @@ export function readSignals(input: CheerInput): Signals { else if (st === 'restamp') restampKeys.add(key); // a starved BOT pull shouldn't tap you with "your turn — you're the best // fit"; bots are handled on their own low-priority cadence, not the rotation - if (!mine && !isBot(p) && !claims[key] && turnFor(p, pools, pulls) === me) turns.set(key, p); + if (!mine && !isBot(p) && !claimOf(p) && turnFor(p, pools, pulls) === me) turns.set(key, p); } - const reviewableUnclaimed = pulls.filter( - p => actionState(p, me) === 'review' && !claims[pullKey(p.data)] - ); + const reviewableUnclaimed = pulls.filter(p => actionState(p, me) === 'review' && !claimOf(p)); const quickWinCandidates = crSort( reviewableUnclaimed.filter( @@ -385,7 +392,6 @@ export function readSignals(input: CheerInput): Signals { // calls a dependabot bump "the single best pull to review next" — it's only // the best when nothing human is left const bestStart = dealFrom(dealRank(reviewableUnclaimed, { me, pulls, deprioritize: isBot }), { - claims, passed: new Set(), }); const startReason = bestStart ? startHereReason(bestStart, pulls, me, true) : ''; @@ -434,29 +440,32 @@ export function readSignals(input: CheerInput): Signals { // claims of yours gone stale that you still haven't stamped — the nudge to // either finish the review or release it back to the pool. The threshold is // the viewer's claim-warn setting (30m–4h), so the copy has to say the real - // number, not a hardcoded "a couple hours" + // number, not a hardcoded "a couple hours". A claim with no `at` (metadata + // not yet backfilled after a server restart) can't be proven stale, so it + // never nags — same rule model/actions.ts's withCoordination applies. const warnMs = input.claimWarnMs ?? STALE_CLAIM_MS; const staleClaimAfter = durationPhrase(warnMs); const staleClaims = new Map(); for (const p of pulls) { const key = pullKey(p.data); - const c = claims[key]; - if (c && c.login === me && !hasStamp(p, me) && now - c.at > warnMs) { + const c = claimOf(p); + if (c && c.login === me && c.at != null && !hasStamp(p, me) && now - c.at * 1000 > warnMs) { staleClaims.set(key, p); } } - // GitHub asked you to review these and you haven't stamped or claimed them — - // a direct request, distinct from the rotation's guess (which stays silent - // on requested pulls). Drives the review-requested toast. + // GitHub asked you to review these and you haven't stamped them — a direct + // request, distinct from the rotation's guess (which stays silent on + // requested pulls). reviewRequestedFrom already excludes a self-claim (see + // model/reviewers.ts), so a pull you've claimed yourself never lands here. + // Drives the review-requested toast. const requestedOfMe = new Map(); for (const p of pulls) { const key = pullKey(p.data); if ( (p.status === 'needs_cr' || p.status === 'needs_recr') && reviewRequestedFrom(p, me) && - !p.crBy.includes(me) && - claims[key]?.login !== me + !p.crBy.includes(me) ) { requestedOfMe.set(key, p); } diff --git a/frontend-v2/src/model/deal.test.ts b/frontend-v2/src/model/deal.test.ts index 07afd412..ca178226 100644 --- a/frontend-v2/src/model/deal.test.ts +++ b/frontend-v2/src/model/deal.test.ts @@ -20,6 +20,7 @@ function dp(o: { additions?: number; deletions?: number; headPushedAt?: number | null; + reviewRequests?: { login: string; at: number | null; self: boolean }[]; }): DerivedPull { return { data: { @@ -30,6 +31,7 @@ function dp(o: { updated_at: new Date(Date.now() - 3600_000).toISOString(), additions: o.additions ?? 10, deletions: o.deletions ?? 0, + review_requests: o.reviewRequests, }, crHave: o.crHave ?? 0, crBy: o.crBy ?? [], @@ -44,13 +46,11 @@ function dp(o: { } type TestOpts = DealRankOptions & { - claims: Readonly>; passed: ReadonlySet; }; const baseOpts = (over: Partial = {}): TestOpts => ({ me: 'me', pulls: [] as DerivedPull[], - claims: {}, passed: new Set(), ...over, }); @@ -64,9 +64,12 @@ describe('deal rank+from — exclusion', () => { }); it('excludes a claimed pull', () => { - const claimed = dp({ number: 1 }); + const claimed = dp({ + number: 1, + reviewRequests: [{ login: 'alice', at: Date.now() / 1000, self: true }], + }); const open = dp({ number: 2 }); - const opts = baseOpts({ claims: { 'org/repo#1': { login: 'alice', at: Date.now() } } }); + const opts = baseOpts(); expect(deal([claimed, open], opts)).toBe(open); }); @@ -78,10 +81,12 @@ describe('deal rank+from — exclusion', () => { }); it('returns null when every candidate is claimed or passed', () => { - const a = dp({ number: 1 }); + const a = dp({ + number: 1, + reviewRequests: [{ login: 'alice', at: Date.now() / 1000, self: true }], + }); const b = dp({ number: 2 }); const opts = baseOpts({ - claims: { 'org/repo#1': { login: 'alice', at: Date.now() } }, passed: new Set(['org/repo#2']), }); expect(deal([a, b], opts)).toBeNull(); diff --git a/frontend-v2/src/model/deal.ts b/frontend-v2/src/model/deal.ts index d75d056a..dbb77573 100644 --- a/frontend-v2/src/model/deal.ts +++ b/frontend-v2/src/model/deal.ts @@ -33,6 +33,15 @@ function hasStamp(p: DerivedPull, login: string): boolean { return p.crBy.includes(login) || p.qaBy.includes(login); } +/** Whether anyone has claimed this pull — a self-requested review, read + * straight off the wire (pull.review_requests). Mirrors store.ts's claimFor; + * duplicated rather than imported so this stays a pure model function + * independent of the (browser-coupled) store module, like every other file + * in model/. */ +function isClaimed(p: DerivedPull): boolean { + return (p.data.review_requests ?? []).some(r => r.self && r.login !== p.data.user.login); +} + /** * The score a candidate earns, higher is better: * - urgency: a starved pull's starveScore (already age × size weighted), or @@ -83,17 +92,13 @@ export function dealRank(pool: DerivedPull[], opts: DealRankOptions): DerivedPul export function dealFrom( ranked: DerivedPull[], opts: { - // store.ts's claimFor does the same lookup; duplicated rather than - // imported so this stays a pure model function independent of the - // (browser-coupled) store module, like every other file in model/. - claims: Readonly>; /** session-only: keys the viewer has already passed on this sitting. */ passed: ReadonlySet; } ): DerivedPull | null { for (const p of ranked) { const key = pullKey(p.data); - if (opts.passed.has(key) || opts.claims[key]) continue; + if (opts.passed.has(key) || isClaimed(p)) continue; return p; } return null; diff --git a/frontend-v2/src/model/reviewers.ts b/frontend-v2/src/model/reviewers.ts index c10894d9..50cb9925 100644 --- a/frontend-v2/src/model/reviewers.ts +++ b/frontend-v2/src/model/reviewers.ts @@ -8,23 +8,45 @@ import type { DerivedPull } from './status'; * rotation: turnFor stays silent whenever a pull carries one, and the note / * row highlight speak for the request instead. * + * A claim (store.ts's claimFor) is ALSO a requested_reviewers entry now — the + * server adds the claimant as a GitHub reviewer — so requestedReviewers/ + * reviewRequestedFrom read the `review_requests` metadata to tell the two + * apart: an entry with self === true is you (or whoever) asking to review it + * yourself, not GitHub asking on the author's behalf, so it's excluded from + * "review this" here. hasReviewRequest stays broader on purpose (see below). + * * Kept viewer-agnostic and side-effect-free like the rest of model/. The * "is it mine to review" question is answered at call sites that know `me`. */ /** logins GitHub has an open review request from, minus the author (GitHub - * never requests a review from the PR's own author, but guard anyway). */ -export function requestedReviewers(p: DerivedPull): string[] { + * never requests a review from the PR's own author, but guard anyway). Counts + * self-requested (claimed) logins too — this is the raw wire list. */ +function allRequestedReviewers(p: DerivedPull): string[] { return (p.data.requested_reviewers ?? []).filter(login => login !== p.data.user.login); } -/** GitHub has explicitly asked `me` to review this pull. */ +/** logins GitHub has an open review request from on the AUTHOR's behalf — a + * real "please review this", excluding anyone whose request is a claim + * (review_requests metadata marks it self === true). Metadata is best-effort: + * absent (older server) or no matching entry means "can't prove it was a + * claim", so the login still counts here. */ +export function requestedReviewers(p: DerivedPull): string[] { + const selfLogins = new Set((p.data.review_requests ?? []).filter(r => r.self).map(r => r.login)); + return allRequestedReviewers(p).filter(login => !selfLogins.has(login)); +} + +/** GitHub (the author's side) has explicitly asked `me` to review this pull — + * false when the only request for `me` is their own claim. */ export function reviewRequestedFrom(p: DerivedPull, me: string): boolean { return requestedReviewers(p).includes(me); } -/** The pull carries any open GitHub review request at all — the signal - * turnFor checks to know it should defer to GitHub rather than rotate. */ +/** The pull carries any open GitHub review request at all, claims included — + * the signal turnFor checks to know it should defer rather than rotate. A + * claimed pull already has someone on it, same as an author-requested one, so + * this deliberately does NOT exclude self-requests the way requestedReviewers + * does. */ export function hasReviewRequest(p: DerivedPull): boolean { - return requestedReviewers(p).length > 0; + return allRequestedReviewers(p).length > 0; } diff --git a/frontend-v2/src/notifications.ts b/frontend-v2/src/notifications.ts index 870bc888..53a7e422 100644 --- a/frontend-v2/src/notifications.ts +++ b/frontend-v2/src/notifications.ts @@ -125,6 +125,14 @@ export function testNotification() { /** At most this many individual alerts per update; a rare bigger burst collapses. */ const MAX_PER_TICK = 5; +/** Whether anyone has claimed this pull — a self-requested review, read + * straight off the wire (pull.review_requests). Mirrors store.ts's claimFor; + * duplicated rather than imported so this file stays independent of the + * store's socket/browser plumbing. */ +function isClaimed(p: DerivedPull): boolean { + return (p.data.review_requests ?? []).some(r => r.self && r.login !== p.data.user.login); +} + /** * Desktop notifications for every real transition that lands on you — your PR * going mergeable, breaking CI, getting feedback or needing a rebase, and @@ -145,7 +153,6 @@ const MAX_PER_TICK = 5; export function useNotifications( pulls: DerivedPull[], me: string, - claims: Readonly> = {}, /** the board's first payload has arrived. While false the board is still * loading (an empty snapshot); recording that as the baseline would make * every real pull look "new" and fire a backlog of alerts once data lands. */ @@ -196,7 +203,7 @@ export function useNotifications( } const key = pullKey(p.data); const turn = turnFor(p, pools, pulls); - if (turn === me && !claims[key]) current.set(key, TURN_ACTION); + if (turn === me && !isClaimed(p)) current.set(key, TURN_ACTION); } // record the baseline but stay silent while unprimed or focused @@ -218,5 +225,5 @@ export function useNotifications( } if (fired > 0 && s.notifySound) chime(); seen.current = current; - }, [ready, pulls, me, claims]); + }, [ready, pulls, me]); } diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index 453f5e59..c779adaf 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -74,11 +74,9 @@ export interface Settings { * these floats to the top of the review queue. Arbitrary strings, not a * known set — unlike repos/logins. */ codeRegions: string[]; - /** how long your review claims last before the server expires them, sent - * to the server as the ttlMs on claimReview */ - claimLengthMins: number; /** when an unfinished claim of yours starts nagging you to finish or - * release it */ + * release it. Claims themselves don't expire on a timer — they clear when + * the review is submitted, released, or removed on GitHub. */ claimWarnMins: number; /** open a PR in a new tab when you click its card, so the board stays put * behind you — this is a hub. Off opens it in the same tab. */ @@ -112,7 +110,6 @@ export const DEFAULT_SETTINGS: Settings = { starredPeople: [], mutedPeople: [], codeRegions: [], - claimLengthMins: 240, claimWarnMins: 120, openPrsNewTab: true, hoverDelayMs: 250, diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts index 982aefd2..4f83fc92 100644 --- a/frontend-v2/src/store.ts +++ b/frontend-v2/src/store.ts @@ -1,8 +1,8 @@ import { useSyncExternalStore } from 'react'; -import { backend, type ConnectionState, type ReviewClaims } from './backend/socket'; +import { backend, type ConnectionState } from './backend/socket'; import { derive, type DerivedPull, type Weight } from './model/status'; import { getSettings, subscribeSettings } from './settings'; -import { epoch, pullKey } from './format'; +import { epoch } from './format'; import { readStorage, writeStorage } from './storage'; import type { PullData, RepoSpec } from './types'; @@ -36,10 +36,6 @@ export interface Snapshot { /** "Refresh all" in progress (or just finished): how many of the pulls * queued at kickoff have reported back. Null when no refresh is running. */ refreshProgress: { done: number; total: number } | null; - /** pull key → who's claimed to review it and when (server-owned: 4h - * expiry, last-writer-wins). The whole map, replaced on every server - * update — see backend/socket.ts's onReviewClaims. */ - claims: Readonly; } const LAST_SEEN_KEY = 'pd2.lastSeen'; @@ -54,7 +50,6 @@ let connection: ConnectionState = 'connecting'; let initialized = false; let authFailed = false; let lastPayloadAt = 0; -let claims: ReviewClaims = {}; const listeners = new Set<() => void>(); // "Refresh all" progress: the set of pull keys still awaiting a pullChange @@ -209,7 +204,6 @@ let snapshot: Snapshot = { acked: { ...acked }, snoozed: { ...snoozed }, refreshProgress: null, - claims: {}, }; // derive() is pure per (pull, spec, warnDays, minute): cache on reference @@ -283,7 +277,6 @@ function publish() { total: refreshTracking.total, } : null, - claims: { ...claims }, }; for (const fn of listeners) fn(); } @@ -336,10 +329,6 @@ function start() { connection = state; schedulePublish(); }); - backend.onReviewClaims(map => { - claims = map; - schedulePublish(); - }); // the aging threshold feeds derive(): re-derive when the user changes it subscribeSettings(schedulePublish); // time-based derivations (iterating, "Nm ago") expire even on a quiet board @@ -359,21 +348,25 @@ export function usePulldasher(): Snapshot { export const refreshPull = backend.refreshPull; -/** Whoever's claimed to review this pull right now, or null. Pure — reads - * whatever claims map the caller hands it (usually the snapshot's), so a Row - * and a test fixture agree on the same key convention. */ -export function claimFor( - pull: PullData, - claims: Readonly -): { login: string; at: number } | null { - return claims[pullKey(pull)] ?? null; +/** Whoever's claimed to review this pull right now, or null. GitHub is the + * source of truth: a claim IS a review request the reviewer made on + * themselves, so this reads straight off the wire (pull.review_requests) — + * no map, no server round trip to reconcile. `at` is epoch seconds, null when + * the server can't say (e.g. it restarted before the webhook backfilled it) — + * callers that show "X ago" must handle that case rather than assume a + * number. */ +export function claimFor(pull: PullData): { login: string; at: number | null } | null { + const entry = (pull.review_requests ?? []).find(r => r.self && r.login !== pull.user.login); + return entry ? { login: entry.login, at: entry.at } : null; } /** Claim/release actions: thin wrappers so callers (Row, toasts) go * through the store like every other mutation instead of reaching into the - * backend directly. */ + * backend directly. Claims no longer expire on a timer — they clear when the + * review is submitted, released, or removed on GitHub — so there's no TTL to + * send. */ export function claimReview(pull: Pick): void { - backend.claimReview(pull.repo, pull.number, getSettings().claimLengthMins * 60_000); + backend.claimReview(pull.repo, pull.number); } export function releaseReview(pull: Pick): void { backend.releaseReview(pull.repo, pull.number); diff --git a/frontend-v2/src/toasts.tsx b/frontend-v2/src/toasts.tsx index 9062a7ac..1c2e31ac 100644 --- a/frontend-v2/src/toasts.tsx +++ b/frontend-v2/src/toasts.tsx @@ -191,7 +191,6 @@ interface LiveToast extends Toast { export function useToasts( pulls: DerivedPull[], me: string, - claims: Readonly> = {}, extras: Toast[] = [], closed: PullData[] = [], /** clicking the quick-wins toast filters the board to the small ones rather @@ -334,7 +333,6 @@ export function useToasts( pulls, closed, me, - claims, now: Date.now(), claimWarnMs: getSettings().claimWarnMins * 60_000, muted: new Set(getSettings().mutedCheers as ToastKind[]), @@ -359,7 +357,7 @@ export function useToasts( return t; }); if (on) push(bound); - }, [ready, pulls, closed, me, claims, push, onQuickWins, onClaimTurn]); + }, [ready, pulls, closed, me, push, onQuickWins, onClaimTurn]); // pre-built one-shot toasts from the caller (e.g. the shipped catch-up), // deduped by dedupeKey so the same logical toast never re-fires on a later diff --git a/frontend-v2/src/types.ts b/frontend-v2/src/types.ts index 5b448d20..cb90b18b 100644 --- a/frontend-v2/src/types.ts +++ b/frontend-v2/src/types.ts @@ -82,6 +82,15 @@ export interface PullData { * this" signal — stronger than our heuristic turn rotation, which defers to * it. Omitted by servers older than the field, so read as [] when absent. */ requested_reviewers?: string[]; + /** the requested_reviewers entries, with per-request metadata: `at` is when + * the request was made (epoch seconds, null when the server can't say — + * e.g. it restarted before the webhook backfilled it), and `self` is true + * when the reviewer requested themselves (a pulldasher claim or a + * GitHub-UI self-request) rather than being asked by someone else. A CLAIM + * is any entry with self === true. Best-effort and additive: older servers + * omit the field entirely, so read as [] when absent — requested_reviewers + * stays the authoritative list of who's requested either way. */ + review_requests?: Array<{ login: string; at: number | null; self: boolean }>; // the wire also sends top-level cr_req/qa_req twins, but status.cr_req/ // qa_req are the ones every consumer reads — typing one copy prevents // reading the wrong one diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index dbc67922..484083c7 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -31,8 +31,7 @@ export function MyWork({ const me = opts.me; const mine = pulls.filter(p => p.data.user.login === me); const byUrgency = (a: DerivedPull, b: DerivedPull) => b.ageDays - a.ageDays; - const kindOf = (p: DerivedPull) => - rowWord(p, me, { claim: claimFor(p.data, opts.claims ?? {}) }).kind; + const kindOf = (p: DerivedPull) => rowWord(p, me, { claim: claimFor(p.data) }).kind; const move = mine.filter(p => kindOf(p) === 'do').sort(byUrgency); const waiting = mine.filter(p => kindOf(p) !== 'do').sort(byUrgency); const shipped = closed.filter(p => p.user.login === me); diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 30302fa5..cca37cfb 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -17,15 +17,7 @@ import { useSettings } from '../settings'; import { claimFor, isFresh } from '../store'; import type { PullData } from '../types'; import { EmptyState, STATUS_DOT, STATUS_LABEL } from '../components/bits'; -import { - Fold, - FoldRows, - Lane, - laneShown, - RestGroup, - SubDoor, - Truncated, -} from '../components/Lane'; +import { Fold, FoldRows, Lane, laneShown, RestGroup, SubDoor, Truncated } from '../components/Lane'; import { RegionHint } from '../components/RegionHint'; import type { RowOptions } from '../components/Row'; import { eyebrowText, WordGroupRows } from '../components/WordGroups'; @@ -250,7 +242,7 @@ export function Review({ // PRs you've claimed — the coordination lane so a claim isn't just a hand // icon buried in a lower lane; it's your commitment, surfaced up top. - const claimed = crSort(pulls.filter(p => opts.claims?.[pullKey(p.data)]?.login === me)); + const claimed = crSort(pulls.filter(p => claimFor(p.data)?.login === me)); // A push answered the feedback, so the ball is back with whoever asked for // changes — their move now is to re-review, not to wait some more. @@ -269,7 +261,7 @@ export function Review({ yourMove.push(p); } const doRankOf = (p: DerivedPull) => { - const word = rowWord(p, me, { claim: claimFor(p.data, opts.claims ?? {}) }).word; + const word = rowWord(p, me, { claim: claimFor(p.data) }).word; const idx = DO_WORD_RANK.indexOf(word); return idx === -1 ? Number.POSITIVE_INFINITY : idx; }; @@ -292,7 +284,7 @@ export function Review({ yoursWaiting.push(p); } const waitRankOf = (p: DerivedPull) => { - const word = rowWord(p, me, { claim: claimFor(p.data, opts.claims ?? {}) }).word; + const word = rowWord(p, me, { claim: claimFor(p.data) }).word; const idx = WAIT_WORD_RANK.indexOf(word); return idx === -1 ? Number.POSITIVE_INFINITY : idx; }; @@ -461,14 +453,15 @@ export function Review({

One score ranks every card:

- PRs that have waited {opts.ageWarnDays ?? 4}+ days for review jump to the - top, oldest and biggest first, even from repos you don’t usually review. + PRs that have waited {opts.ageWarnDays ?? 4}+ days for review jump to the top, + oldest and biggest first, even from repos you don’t usually review.

After those: PRs in repos you’ve reviewed before, PRs from people who review your work, and small quick wins, lightest first. PRs from people you starred always come first; bot PRs (dependency bumps) sink to the bottom.

+

PRs you claim stay in the queue and also appear in Waiting on you.

} pulls={queue} From 8f17f07e0cb215a9eaf39d1d8e3bbf38157e3a3e Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 10:32:38 -0700 Subject: [PATCH 254/381] Weight is a letter in the CR cluster; the age line answers hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rail changes from the owner's review of the card, both in the direction of quiet: - The 112px weight ruler under the sign-off marks is gone. Review weight is now a letter riding the CR cluster (CR · S, CR · XL, ? for unknown sizes) in the same fixed 18px slot and 11px ink the CI/CR/QA labels use, with the owner's middot keeping label and letter from fusing into one token. The letter opens the exact popover the strip had: the effort word, the +/- diff, the filter action. The rail collapses to a single vertically-centered line; the circle marks become its only geometry, which is where the eye should land anyway. - The age hairline is now a door of its own. At rest it is pixel- identical (1px, relative width, grey ramp); a 10px-tall invisible strip along the row's bottom edge makes it hoverable without pixel hunting, and on hover the line grows to an 8px band and opens the same age popover the numeral carries (opened, last activity, and the relative-to-oldest explanation). One content, two doors. WeightMeter had no remaining consumers and is deleted; the Legend shows the letter vocabulary and Pips/SigPips hand their visible label to the caller (RailLabel) so the letter can sit between label and marks without nesting buttons. lucide-react lands in package.json for the icon unification that follows. Co-Authored-By: Claude Opus 4.8 --- DESIGN.md | 24 ++- frontend-v2/package-lock.json | 10 + frontend-v2/package.json | 1 + frontend-v2/src/components/Card.tsx | 3 +- frontend-v2/src/components/Legend.tsx | 14 +- frontend-v2/src/components/Row.tsx | 144 +++++++------ frontend-v2/src/components/bits.tsx | 191 ++++++++++-------- .../src/components/filters/WeightFilter.tsx | 2 +- frontend-v2/src/styles.css | 30 ++- 9 files changed, 257 insertions(+), 162 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index f6ee0329..c566b59f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -54,9 +54,10 @@ in the section header, not on the card. **Topical doors, not one mega-popover.** Each rail mark opens its own detail popover scoped to that mark: the CI bar → the per-check list, the -CR/QA pips → per-signer names and times, the weight strip → the exact +/− -diff. Detail lives one hover from the mark that hints at it; the repo# door -summarizes all of it in prose. This split is deliberate — merging every +CR/QA pips → per-signer names and times, the weight letter → the exact +/− +diff, the age line → opened/last-activity. Detail lives one hover from the +mark that hints at it; the repo# door summarizes all of it in prose. This +split is deliberate — merging every fact into one popover would bury the answer to the question the user's cursor is already pointing at. @@ -115,14 +116,15 @@ member of the sign-off family (it did, when weight was a dot): | Shape | Metric | Behavior | |---|---|---| | circle marks (14px SVG masks, `.pip`) | CI/CR/QA sign-off | ONE family for all three reviewers — the machine is a reviewer too. The label names the reviewer, the glyph names the verdict (✓ approved, ✗ failed, ring = needed/running), the treatment names the standing (solid stands, outlined lapsed). CI is invisible at rest unless failing — its quiet states render in a reserved slot at opacity 0, revealed on row hover, so the reveal can never reflow the line. **The alarm exception**: a failing CI's X disc is the ONE mark drawn larger than the pips (18px, `.pip-alarm`), its label and count in red — broken is the board's rarest and most consequential state, so it escalates by form, never by fill (a filled red chip was prototyped and rejected as badge regression) | -| horizontal ratio strip (4px, under the marks) | review weight | one quiet ink fill on a fixed-extent track; fill **doubles per class** (6/13/25/50/100%) because effort doubles per class — exponential honesty beats linear prettiness | -| the row's baseline (1px hairline, bottom edge) | age / starvation | **gated, not always-on**: nothing below the aging threshold, then a grey hairline — a deepened stretch of the divider the row already has, never a drawn bar. **Relative, not thresholded**: the board's longest-open pull sets the full track and the deepest tint (ultra-light ink → full ink-3); every other row is a fraction of the oldest. **Grey only** — the amber version was disruptive and a red plateau before it read as "broken" everywhere; age is a quiet fact, urgency lives in the queue's ranking and the numeral's font weight. The quiet day count floats right in the meta line, capping the track | - -The rail is one instrument: `CI · CR · QA` marks in a row, the weight -strip beneath them. Age lives on the row's own bottom edge, not in the -rail — the track is a line the row already had. When a signal moves onto -a better mark, remove it from the old one in the same change, or the card -gets louder instead of clearer. +| quiet text letter (11px, same fixed slot as the CI/CR/QA label) | review weight | XS/S/M/L/XL, or "?" when the wire sent no size — not its own shape at all, deliberately: it rides inside the CR cluster (`CR` label · weight letter · pips) instead of inventing a fourth family or drawing a ratio strip. A prior 4px fill-doubles-per-class strip under the whole sign-off row was retired (2026-07): the owner found it visually loud and it only ever said what a letter already says. Same popover survives the move — the effort word, the exact +/− diff, and the "Filter to X PRs" action | +| the row's baseline (1px hairline, bottom edge) | age / starvation | **gated, not always-on**: nothing below the aging threshold, then a grey hairline — a deepened stretch of the divider the row already has, never a drawn bar. **Relative, not thresholded**: the board's longest-open pull sets the full track and the deepest tint (ultra-light ink → full ink-3); every other row is a fraction of the oldest. **Grey only** — the amber version was disruptive and a red plateau before it read as "broken" everywhere; age is a quiet fact, urgency lives in the queue's ranking and the numeral's font weight. The quiet day count floats right in the meta line, capping the track. **The line is also a door** (2026-07): a taller invisible hit strip (10px, sized to the line's own drawn fraction, not the full row) makes a 1px target hoverable without pixel-hunting; hover or focus grows the line to an 8px band and opens the same age popover the numeral shows (opened X ago, last activity Y ago, the relative-to-the-oldest line) — one popover body, two doors, so they can't drift apart | + +The rail is one instrument, one line: `CI` then the `CR` cluster (label, +weight letter, sign-off pips) then the `QA` cluster (label, pips). Age +lives on the row's own bottom edge, not in the rail — the track is a line +the row already had, now a hover door in its own right. When a signal moves +onto a better mark, remove it from the old one in the same change, or the +card gets louder instead of clearer. Marks are drawn as SVG masks / CSS geometry, never font glyphs — a text ✓ at 10px is at the mercy of the platform rasterizer (a struck-through ✓ was diff --git a/frontend-v2/package-lock.json b/frontend-v2/package-lock.json index 8b5cbafd..1df2c8ab 100644 --- a/frontend-v2/package-lock.json +++ b/frontend-v2/package-lock.json @@ -8,6 +8,7 @@ "name": "pulldasher-frontend-v2", "version": "0.0.1", "dependencies": { + "lucide-react": "^1.25.0", "react": "^18.3.1", "react-dom": "^18.3.1", "socket.io-client": "^4.8.3" @@ -2289,6 +2290,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", + "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/frontend-v2/package.json b/frontend-v2/package.json index 4914d16c..ce66ec80 100644 --- a/frontend-v2/package.json +++ b/frontend-v2/package.json @@ -12,6 +12,7 @@ "preview": "vite preview" }, "dependencies": { + "lucide-react": "^1.25.0", "react": "^18.3.1", "react-dom": "^18.3.1", "socket.io-client": "^4.8.3" diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index d10e8c6c..60b65069 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -85,7 +85,8 @@ export function CardShell({ avatarBadge?: ReactNode; /** a row-edge overlay (the age baseline): absolutely positioned against * the pd-row (already relative), rendered last so it paints over the - * divider without entering the flex flow. */ + * divider without entering the flex flow. Hoverable/focusable now (its + * own popover door), not pure decoration. */ edge?: ReactNode; /** stack-nesting depth (0 = top-level): indents the row and shows a * connector elbow before the avatar — model/stack.ts's groupIntoTree diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 74b97068..bf9fea23 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; import { useSettings } from '../settings'; -import { DiffSize, WeightMeter } from './bits'; +import { DiffSize } from './bits'; import { Popover } from './Popover'; import { eyebrowText } from './WordGroups'; @@ -119,17 +119,17 @@ export function Legend() { /> } - def={`age: a grey hairline along a row's bottom edge appears once it's ${s.ageWarnDays}+ days without full review; its length and depth are relative to the board's longest-open pull, so the oldest runs full width in full grey. The quiet day count at the row's right edge caps it and just gets bolder; hover it for both clocks`} + def={`age: a grey hairline along a row's bottom edge appears once it's ${s.ageWarnDays}+ days without full review; its length and depth are relative to the board's longest-open pull, so the oldest runs full width in full grey. Hover the line itself (it widens) or the quiet day count at the row's right edge for both clocks`} /> - - - + + XS + M + XL } - def="review effort: the strip under the CR/QA marks fills with the review’s weight, doubling per class: a sliver = a quick pickup, full = very heavy. Hover for the exact +/− lines, click to filter" + def="review effort: the letter beside the CR marks, lightest to heaviest (a “?” means the wire sent no size). Hover for the word and the exact +/− lines, click to filter" />
diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 1691c873..87505cd3 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -1,6 +1,6 @@ import { memo, useState, type ReactNode } from 'react'; import type { DerivedPull } from '../model/status'; -import { isIterating, lastPushEpoch } from '../model/status'; +import { isIterating, lastPushEpoch, weightFilterKey } from '../model/status'; import { type Claim, rowNote } from '../model/actions'; import { matchedRegions } from '../model/regions'; import type { ParentRef } from '../model/stack'; @@ -31,7 +31,6 @@ import { RepoRef, SigPips, WEIGHT_WORD, - WeightMeter, } from './bits'; import { CardShell } from './Card'; import { StatePopover } from './StatePopover'; @@ -562,25 +561,44 @@ function RowActionsKebab({ pull, claim }: { pull: DerivedPull; claim: Claim | nu } /** - * The weight chip as both a filter toggle and a hover preview: click adds/ - * removes that size class from the session Weight filter — the same bucket - * WeightFilter's own checkboxes drive, just row-initiated — while hovering - * previews the exact +/− diff size the letter is standing in for. Falls back - * to a plain, non-interactive trigger (still inside the same popover) when no - * callback is wired up (e.g. a lens that hasn't threaded - * RowOptions.onWeightToggle). + * The fixed-width label a rail slot leads with — CI, CR, QA, and the weight + * letter all share this exact 18px/11px/font-medium slot, so whichever ones + * a row shows still land at the same x down a board. aria-hidden: the + * interactive mark beside it (a pip cluster or a popover trigger) carries + * the accessible name. */ -function WeightChip({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { - const meter = ; +function RailLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * Review weight as a letter in the CR cluster — XS/S/M/L/XL, or "?" when the + * wire sent no size — not the strip that used to run under the whole + * sign-off row (the owner found the bar visually loud and it only ever said + * what a letter already can). Same fixed 18px slot as the CI/CR/QA labels: + * "XS" and "XL" are the widest cases this ever renders, exactly as wide as + * those two-letter labels, so the letter never nudges the pips beside it. + * Click/hover opens the same popover the old strip did: the effort word, the + * exact +/− diff, and a "Filter to X PRs" toggle into the session Weight + * filter (falls back to a plain trigger when no lens has wired + * RowOptions.onWeightToggle up). + */ +function WeightLetter({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { + const { weight, sizeKnown } = pull; + const word = WEIGHT_WORD[weight]; + const letter = sizeKnown ? weight : '?'; const onWeightToggle = opts.onWeightToggle; - const key = pull.sizeKnown ? pull.weight.toLowerCase() : 'unknown'; const d = pull.data; return ( - - {meter} - + {letter} )} > - - review effort: {WEIGHT_WORD[pull.weight]} - - {pull.sizeKnown && ( + review effort: {word} + {sizeKnown && ( )} - {pull.sizeKnown ? 'from diff size' : 'size estimated'} + {sizeKnown ? 'from diff size' : 'size estimated'} {onWeightToggle && ( - onWeightToggle(key)}> - Filter to {pull.sizeKnown ? pull.weight : 'unknown-size'} PRs + onWeightToggle(weightFilterKey(pull))}> + Filter to {sizeKnown ? weight : 'unknown-size'} PRs )} @@ -624,13 +643,14 @@ function WeightChip({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { } /** - * The metric rail every row ends on, in one order everywhere: CI, then the CR - * and QA sign-off pips, then how heavy to review — weight is the rightmost - * anchor now, the "can I fit this in" scan column reviewers hunt first. Age - * moved to the meta line (see RowImpl): it's a fact about the pull, not a - * per-row action like the rest of this rail. Right-anchored and - * fixed-geometry, so it reads as vertical columns down any lens. Raised above - * the card's click layer so the sign-off popovers still open. + * The metric rail every row ends on, in one order everywhere: CI, then CR + * (label, weight letter, sign-off pips), then QA (label, pips). One line, + * vertically centered — the weight ruler that used to run underneath is + * gone; the letter says the same thing inside the CR cluster instead. + * Age lives on the row's own baseline, not in the rail (see RowImpl). Right- + * anchored and fixed-geometry, so it reads as vertical columns down any + * lens. Raised above the card's click layer so the sign-off popovers still + * open. */ function MetricRail({ pull, @@ -652,33 +672,39 @@ function MetricRail({ {/* one instrument: the three reviewers' marks in a row — machine - first, then the humans — with the weight strip underneath. CI is - invisible at rest unless failing (revealed on row hover), in a - reserved slot so nothing shifts. Age lives on the row's - baseline, not in the rail. */} - - - - - + first, then the humans, weight riding along inside the CR + cluster. CI is invisible at rest unless failing (revealed on row + hover), in a reserved slot so nothing shifts. */} + + + CR + {/* the owner's sketch was "CR · S ✓✓": the middot keeps the + label and the size letter from fusing into one token at 11px */} + + · - + + + + + QA + ); @@ -801,6 +827,8 @@ function RowImpl({ edge={ = { XL: 'very heavy', }; -/** - * Review effort as a horizontal RATIO strip under the whole sign-off - * section (CR and QA — weight is how heavy the REVIEW is, both halves), not - * its own rail slot. The long shared extent gives the exponential fill real - * resolution, and the fill doubles per class — 6/13/25/50/100% — because review - * effort roughly doubles per class, so the lengths separate honestly without - * color carrying anything. Faded whole when the wire didn't send a size; the - * hover popover has the word and exact +/− lines. - */ -export function WeightMeter({ - weight, - known = true, - wide = false, -}: { - weight: Weight; - known?: boolean; - /** the rail's full-size track: a FIXED 112px ruler, right-anchored under - * the marks — the sign-off cell's width varies (one CR slot vs two, a - * failing count), and a track that stretched with it made identical - * weights render different lengths row to row. Same ruler on every row, - * or the fractions lie. Default is the 36px mini track (legend, samples). - */ - wide?: boolean; -}) { - const rank = weightRank(weight); - const word = WEIGHT_WORD[weight]; - const label = known ? `review effort: ${word}` : `review effort: ${word} (size estimated)`; - const ratio = [6, 13, 25, 50, 100][rank]; - return ( - - - - ); -} - /** * The concrete diff size beside the abstract weight letter: +added −deleted, * so the exact number is there when the letter chip isn't precise enough. @@ -464,16 +418,22 @@ export function CiStatus({ pull }: { pull: DerivedPull }) { /** * Sign-off state as a circle-check meter: one mark per required stamp, in a - * fixed-width slot so CR, QA, and weight land at the same x down a board. - * One mark, three standings (see styles.css .pip): a solid disc = an - * approval that stands, the same mark drained to an outline = it stood once - * but a push lapsed it (the most actionable state on the board), an empty - * ring = still needed, a muted dash = nothing required. A dotted underline - * marks a slot you personally stamped. No enclosing chip at all: the marks - * are confident enough to stand bare beside their label — every box, wash, - * and hairline this slot has worn turned out to be scaffolding (the earlier - * colored squares and tinted backgrounds were a private code that sent eyes - * to the rail instead of the titles). + * fixed-width slot so CR, QA, CI, and the weight letter land at the same x + * down a board. One mark, three standings (see styles.css .pip): a solid disc + * = an approval that stands, the same mark drained to an outline = it stood + * once but a push lapsed it (the most actionable state on the board), an + * empty ring = still needed, a muted dash = nothing required. A dotted + * underline marks a slot you personally stamped. No enclosing chip at all: + * the marks are confident enough to stand bare beside their label — every + * box, wash, and hairline this slot has worn turned out to be scaffolding + * (the earlier colored squares and tinted backgrounds were a private code + * that sent eyes to the rail instead of the titles). + * + * `label` only feeds the aria/title strings ("CR 2 of 2…") — it renders + * nothing here. The visible "CR"/"QA" glyph is the caller's (Row.tsx), the + * same fixed-width slot the CI label and weight letter use, so a caller can + * slot something else (the weight letter) between the label and these marks + * without it ending up inside this component's own hover/click surface. */ export function Pips({ label, @@ -536,9 +496,6 @@ export function Pips({ aria-label={aria} title={titled ? title : undefined} > - - {label} - {none ? ( + + opened {ago(createdAt)} ago + + + last activity {ago(updatedAt)} ago + + {explainLine && ( + + the grey line under this row is its age, relative to the board’s oldest open pull + + )} + + ); +} + /** * Age as the row's own baseline: a 1px hairline along the bottom edge — a * tinted stretch of the divider the row already has, not a drawn bar. @@ -688,16 +683,28 @@ export function SigPips({ * a colored alarm (an amber version shipped for an hour and was the * loudest thing on the board; urgency belongs to the queue's ranking and * the numeral's weight). Silent below the aging threshold — a healthy - * young row draws nothing. aria-hidden: the numeral's popover carries the - * clocks; this line is pure geometry. + * young row draws nothing. + * + * The line is also a door now, not pure geometry: a 10px invisible hit strip + * (`.pd-age-track`/`.pd-age-hit` in styles.css), sized to the SAME fraction + * as the visible line so a 1px target doesn't need pixel-hunting, opens the + * same age popover the numeral shows on hover or focus and grows the line to + * an 8px band while it's open. At rest it's pixel-identical to the old + * static hairline. */ export function AgeBaseline({ ageDays, + createdAt, + updatedAt, warnDays = STARVE_DAYS, maxAgeDays = 1, quiet, }: { ageDays: number; + /** epoch secs the pull opened — feeds the shared age popover body */ + createdAt: number; + /** epoch secs of the last activity — feeds the shared age popover body */ + updatedAt: number; warnDays?: number; /** the board's longest-open pull — the 100% mark of the track */ maxAgeDays?: number; @@ -710,14 +717,41 @@ export function AgeBaseline({ // ink-3 on the board's oldest const inkPct = Math.round(30 + 70 * t); return ( - + // the percentage width lives on this outer track (not the line itself, + // see styles.css): the line and its taller hit area both fill 100% of + // it, so hovering anywhere along the row's actual age fraction — never + // past it — opens the door. + + ( + + )} + > + + + ); } @@ -790,20 +824,11 @@ export function AgeStamp({ )} > - - opened {ago(createdAt)} ago - - - last activity {ago(updatedAt)} ago - - {/* the hairline can't take a hover itself (1px, aria-hidden), so its - explanation lives here, on the numeral that caps it — only when - the line is actually drawn for this row */} - {!quiet && ageDays >= warnDays && ( - - the grey line under this row is its age, relative to the board’s oldest open pull - - )} + = warnDays} + /> ); } diff --git a/frontend-v2/src/components/filters/WeightFilter.tsx b/frontend-v2/src/components/filters/WeightFilter.tsx index ed58f8ff..5c452c9d 100644 --- a/frontend-v2/src/components/filters/WeightFilter.tsx +++ b/frontend-v2/src/components/filters/WeightFilter.tsx @@ -4,7 +4,7 @@ import { Popover } from '../Popover'; import { FilterRow, OnlyButton } from './shared'; /** Weight filter option order: lightest to heaviest, then the unknown-size - * catch-all — mirrors the WeightMeter's own light-to-heavy read. */ + * catch-all — mirrors the rail's own weight-letter read (XS through XL). */ const WEIGHT_OPTIONS: { key: string; label: string }[] = [ { key: 'xs', label: 'XS · very light' }, { key: 's', label: 'S · light' }, diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index a40223b8..7f993d6a 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -241,13 +241,41 @@ label:has(> input:not(:disabled)) { edge — a deepened stretch of the divider the row already has. Length and tint are both relative to the board's longest-open pull (see bits.tsx AgeBaseline). The quiet day count floats right in the meta - line, capping the track. */ + line, capping the track. width:100% fills its immediate parent, the + hoverable .pd-age-hit button below — not the row — so the line's own + extent stays exactly what it was before the hover door was added. */ .pd-age-line { position: absolute; left: 0; bottom: 0; + width: 100%; height: 1px; pointer-events: none; + /* only the height animates open; the global prefers-reduced-motion + rule at the bottom of this file already zeroes this out */ + transition: height 150ms var(--ease-quart); + } + + /* the line's own door: .pd-age-track is the row-relative track (bits.tsx + sets its width inline to the SAME fraction the line draws, the row's + age relative to the board's oldest, not the full row) — a taller (10px) + box so a 1px line doesn't need pixel-hunting. z-index:1 matches + .pd-raise's tier: enough to sit above the row's stretched-link click + layer (.pd-link::after, z-index 0) inside this one strip, without + stealing a click anywhere else on the row. .pd-age-hit is the button + that fills it; hovering or focusing it grows .pd-age-line to an 8px + band and opens the same age popover the numeral shows (bits.tsx's + AgePopoverBody — one copy, two doors). */ + .pd-age-track { + position: absolute; + left: 0; + bottom: 0; + z-index: 1; + height: 10px; + } + .pd-age-hit:hover .pd-age-line, + .pd-age-hit:focus-visible .pd-age-line { + height: 8px; } /* row flags: quiet colored labels, not badges — amber for act-on-it, From bedce27a9a536eadacdbf39b2e8b078d8a66f5dd Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 11:07:16 -0700 Subject: [PATCH 255/381] One icon family: lucide everywhere a mark isn't a pip An audit found the app's icons split across four coordinate grids (hand-rolled 16, one Heroicons 24, a 20-grid gear, a 36-grid check), mixed fill-vs-stroke conventions, two competing dismiss-X implementations, five copy-pasted star buttons, and a dozen platform-rendered text glyphs (star, diamond, carets, checks) that draw differently on every OS. The owner called it: they don't mesh. Everything is now lucide-react (ISC, inline SVG components, per-icon imports so the bundle only carries what renders) through one small Icon wrapper that pins the house size and aria defaults. One shared StarMark replaces the five stars; one X replaces both dismiss families; the CI popover's per-check marks match the rest of the card instead of gambling on the platform font. The rail pips deliberately stay CSS masks (machined geometry for the decision-critical marks) and the empty-state draw-check stays as the one sanctioned set-piece; DESIGN.md records the rule. Riding along, from the same audit: - STATUS_DOT.needs_recr was brand; a lapsed stamp is owed work, so it's amber now (it reaches the screen via the Stats status bar). - The connection dot's connecting state was amber, which claims 'you owe something' for a state that is purely in-progress; it's slate now, same as CI running. - The region chip was the last bordered badge on a badge-less card, and redundant inside the 'In your code regions' lanes. It is now absent there (the lane header already says it) and a bare 10px brand diamond elsewhere, with the region names one hover away. Co-Authored-By: Claude Opus 4.8 --- DESIGN.md | 21 +++ frontend-v2/src/app.tsx | 9 +- frontend-v2/src/components/Column.tsx | 12 +- frontend-v2/src/components/Icon.tsx | 23 ++++ frontend-v2/src/components/Lane.tsx | 9 +- frontend-v2/src/components/Legend.tsx | 42 +++--- .../src/components/NotificationPanel.tsx | 21 +-- frontend-v2/src/components/RegionHint.tsx | 21 +-- frontend-v2/src/components/RepoManager.tsx | 4 +- frontend-v2/src/components/Row.tsx | 121 ++++++++---------- .../src/components/SavedFiltersPanel.tsx | 24 +--- frontend-v2/src/components/Settings.tsx | 22 +--- frontend-v2/src/components/bits.tsx | 34 +++-- .../src/components/filters/DraftsFilter.tsx | 14 +- .../src/components/filters/FilterChips.tsx | 4 +- .../src/components/filters/PeopleFilter.tsx | 18 +-- .../src/components/filters/RepoFilter.tsx | 18 +-- .../src/components/filters/StateFilter.tsx | 14 +- .../src/components/filters/WeightFilter.tsx | 14 +- frontend-v2/src/styles.css | 13 +- frontend-v2/src/toasts.tsx | 12 +- frontend-v2/src/views/Review.tsx | 3 + frontend-v2/src/views/Team.tsx | 10 +- 23 files changed, 230 insertions(+), 253 deletions(-) create mode 100644 frontend-v2/src/components/Icon.tsx diff --git a/DESIGN.md b/DESIGN.md index c566b59f..8a581f22 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -130,6 +130,27 @@ Marks are drawn as SVG masks / CSS geometry, never font glyphs — a text ✓ at 10px is at the mercy of the platform rasterizer (a struck-through ✓ was conceptually perfect and rendered as a blob). +### Icons + +One family: every icon on the board renders through `lucide-react` (ISC, +per-icon imports so tree-shaking holds) via the single wrapper in +`components/Icon.tsx` — 14px for inline/action marks, 16px for header chrome, +lucide's own stroke weight never overridden (a second weight would be a second +family). A 2026-07 icon audit found four different coordinate grids, fill and +stroke mixed on what should've been one glyph language, and a dozen +platform-rendered text glyphs standing in for marks (★/☆, ▸/▾, ✕, ◆) — all +retired in the same pass. **Text glyphs are banned for marks app-wide now**, +not just on the rail; if a mark needs a new glyph, it comes from lucide. + +Two things stay outside that family on purpose: the rail's CI/CR/QA pips +(`.pip`, above) are CSS masks, not lucide imports — one shape family, sized +and animated in ways an icon library's fixed viewBox can't do, and the +doctrine that governs them predates this pass and isn't part of it. The +`EmptyState` animated draw-check (bits.tsx) is a sanctioned hand-drawn +set-piece: it already speaks lucide's own stroke-and-round-cap language, and +its one-shot draw-on animation is bespoke to this exact SVG, not a lucide +icon with a class bolted on. + ## Layout invariants - The metric rail is a vertically-centered right column on wide rows diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 4052936b..dff9f331 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -7,6 +7,7 @@ import { useState, type ReactNode, } from 'react'; +import { X } from 'lucide-react'; import { ago, closedEpoch, n, pullKey, shortRepo } from './format'; import type { ActionStateKey } from './model/actions'; import { actionState } from './model/actions'; @@ -27,6 +28,7 @@ import { matchesQuery } from './model/query'; import { CRYO_KEY, isBotLogin, personHidden, repoHidden } from './model/visibility'; import { reviewRequestedFrom } from './model/reviewers'; import { foldDomId, openFold } from './components/Lane'; +import { Icon } from './components/Icon'; import { Legend } from './components/Legend'; import { Logo } from './components/Logo'; import { NotificationPanel } from './components/NotificationPanel'; @@ -861,7 +863,10 @@ export function App() { connection === 'connected' ? 'conn-live bg-ok' : connection === 'connecting' - ? 'bg-warn' + ? // in-progress, not an alarm — the same slate hue CI + // running wears, not warn (which means "you owe + // something") + 'bg-slate' : 'bg-bad' }`} title={connection} @@ -964,7 +969,7 @@ export function App() { v1 view: {describeLegacyView(legacy) || 'defaults'} - + )} diff --git a/frontend-v2/src/components/Column.tsx b/frontend-v2/src/components/Column.tsx index a354f3f0..d2a33f6f 100644 --- a/frontend-v2/src/components/Column.tsx +++ b/frontend-v2/src/components/Column.tsx @@ -1,4 +1,6 @@ import { useState, type ReactNode } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { Icon } from './Icon'; /** * The board column shell shared by Board, Classic, and Recently Closed: @@ -33,14 +35,12 @@ export function BoardColumn({ > {/* the disclosure cue the header lacked: without it the only hint this collapses was a native tooltip */} - - ▸ - + /> {header} {/* an empty column still earns its header (muscle memory), but diff --git a/frontend-v2/src/components/Icon.tsx b/frontend-v2/src/components/Icon.tsx new file mode 100644 index 00000000..2fff2a73 --- /dev/null +++ b/frontend-v2/src/components/Icon.tsx @@ -0,0 +1,23 @@ +import type { ForwardRefExoticComponent, SVGProps } from 'react'; + +type LucideComponent = ForwardRefExoticComponent< + SVGProps & { size?: number | string } +>; + +/** + * The one lucide wrapper every icon in the app renders through (per-icon + * imports at the call site keep tree-shaking — never `import * as icons`). + * House defaults: 14px for inline/action icons, pass size={16} for header + * chrome; lucide's own stroke weight is never overridden here — a second + * weight would be a second visual family. aria-hidden by default, since an + * icon almost always rides beside text or inside an already-labeled button; + * pass aria-hidden={false} (with the caller's own accessible name) on the + * rare icon that IS the label. + */ +export function Icon({ + icon: Glyph, + size = 14, + ...rest +}: { icon: LucideComponent } & SVGProps & { size?: number | string }) { + return ; +} diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index 1d3917bc..6cb5ce1e 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -1,8 +1,10 @@ import { useState, type ReactNode, type SyntheticEvent } from 'react'; +import { ChevronRight } from 'lucide-react'; import { pullKey } from '../format'; import type { DerivedPull } from '../model/status'; import { groupIntoTree } from '../model/stack'; import { createPersistentStore } from '../storage'; +import { Icon } from './Icon'; import { Popover } from './Popover'; import { Row, type RowOptions } from './Row'; @@ -305,9 +307,10 @@ export function Fold({ onToggle={id ? onToggle : undefined} > - - ▸ - + {count} {label} {hint && {hint}} diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index bf9fea23..8ed7a7f7 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from 'react'; +import { ChevronRight, CircleHelp, Diamond } from 'lucide-react'; import { useSettings } from '../settings'; import { DiffSize } from './bits'; +import { Icon } from './Icon'; import { Popover } from './Popover'; import { eyebrowText } from './WordGroups'; @@ -61,9 +63,9 @@ export function Legend() { type="button" aria-label="what the symbols mean" title="what the symbols mean" - className="pressable inline-flex h-8 w-8 items-center justify-center rounded-lg border border-line bg-surface text-[13px] font-semibold text-ink-3 hover:text-brand" + className="pressable inline-flex h-8 w-8 items-center justify-center rounded-lg border border-line bg-surface text-ink-3 hover:text-brand" > - ? + )} > @@ -133,9 +135,10 @@ export function Legend() { />
- - ▸ - + How weight is decided
@@ -182,17 +185,18 @@ export function Legend() { /> - region + + } - def="matches a code region you set in Settings; these gather in the “In your code regions” section on Review and Team" + def="matches a code region you set in Settings: a bare mark, hover it for which region(s). These gather in the “In your code regions” section on Review and Team, where the lane heading already says it, so the mark itself doesn’t render there" />
- - ▸ - + How regions work
@@ -204,7 +208,7 @@ export function Legend() { A PR matches when a region appears, case-insensitively, anywhere in the text already on the board: its title, description, repo, branch name, or labels. Changed file paths aren’t checked (the board never fetches them). A match earns - the ◆ chip, floats up your queue, and collects in “In your code regions”. + the diamond mark, floats up your queue, and collects in “In your code regions”.

@@ -229,9 +233,10 @@ export function Legend() { />
- - ▸ - + How claims work
@@ -260,9 +265,10 @@ export function Legend() { />
- - ▸ - + How your turn is picked
diff --git a/frontend-v2/src/components/NotificationPanel.tsx b/frontend-v2/src/components/NotificationPanel.tsx index db2016c6..2417bddb 100644 --- a/frontend-v2/src/components/NotificationPanel.tsx +++ b/frontend-v2/src/components/NotificationPanel.tsx @@ -1,13 +1,11 @@ import { useState } from 'react'; +import { Bell, X } from 'lucide-react'; import { ago, githubUrl } from '../format'; import { useSettings } from '../settings'; import type { ToastRecord } from '../toasts'; +import { Icon } from './Icon'; import { onOpen, Popover } from './Popover'; -// A plain outline bell (16-unit viewBox) — the clapper is a second subpath. -const BELL = - 'M8 1.5A2.5 2.5 0 0 0 5.5 4v.28C4.03 4.9 3 6.36 3 8.06V11l-1.2 1.2A.5.5 0 0 0 2.15 13H13.85a.5.5 0 0 0 .35-.85L13 11V8.06c0-1.7-1.03-3.16-2.5-3.78V4A2.5 2.5 0 0 0 8 1.5Zm1.5 12.5a1.5 1.5 0 0 1-3 0h3Z'; - /** Tone → medallion tint, matching the toast card's own vocabulary so a nudge * reads the same in the panel as it did when it flashed. */ const MEDALLION: Record = { @@ -56,9 +54,7 @@ export function NotificationPanel({ onClick={onOpen(t, () => setLastSeen(Date.now()))} className="pressable relative inline-flex h-8 w-8 items-center justify-center rounded-lg border border-line bg-surface text-ink-3 hover:text-brand" > - - - + {flag && (badge === 'dot' ? ( @@ -125,16 +121,7 @@ export function NotificationPanel({ onClick={() => onDismiss(r.id)} className="hit pressable -m-1 flex-none rounded p-1 text-ink-3 opacity-0 transition-opacity hover:text-ink group-hover/n:opacity-100 focus-visible:opacity-100" > - - - + ))} diff --git a/frontend-v2/src/components/RegionHint.tsx b/frontend-v2/src/components/RegionHint.tsx index a58f7a64..ba4198cd 100644 --- a/frontend-v2/src/components/RegionHint.tsx +++ b/frontend-v2/src/components/RegionHint.tsx @@ -1,4 +1,6 @@ +import { Diamond, X } from 'lucide-react'; import { createPersistentStore } from '../storage'; +import { Icon } from './Icon'; // One-time, per-browser dismissal — a tip you've read once shouldn't nag again. const dismissed = createPersistentStore<{ done: boolean }>('pd2.tip.codeRegions', { @@ -23,13 +25,13 @@ export function RegionHint() { if (gone) return null; return (
- - ◆ + + Review a particular area? Add code regions in Settings. - A region is plain text matched against a PR’s title, labels, branch, and repo; - matches gather in their own section above the queue. + A region is plain text matched against a PR’s title, labels, branch, and repo; matches + gather in their own section above the queue.
); diff --git a/frontend-v2/src/components/RepoManager.tsx b/frontend-v2/src/components/RepoManager.tsx index a7cc5623..06dae5a2 100644 --- a/frontend-v2/src/components/RepoManager.tsx +++ b/frontend-v2/src/components/RepoManager.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { repoState } from '../model/visibility'; -import { QuietButton } from './bits'; +import { QuietButton, StarMark } from './bits'; import { FilterSearch } from './filters/shared'; export function RepoManagerGroup({ @@ -121,7 +121,7 @@ export function RepoManagerGroup({ } title={isPrimary ? 'a repo you review' : 'mark a repo you review'} > - {isPrimary ? '★' : '☆'} + {repo.name} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 87505cd3..f119f560 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -1,4 +1,5 @@ import { memo, useState, type ReactNode } from 'react'; +import { AlarmClock, Copy, Diamond, EllipsisVertical, Hand, RefreshCw, Star } from 'lucide-react'; import type { DerivedPull } from '../model/status'; import { isIterating, lastPushEpoch, weightFilterKey } from '../model/status'; import { type Claim, rowNote } from '../model/actions'; @@ -30,9 +31,11 @@ import { QuietButton, RepoRef, SigPips, + StarMark, WEIGHT_WORD, } from './bits'; import { CardShell } from './Card'; +import { Icon } from './Icon'; import { StatePopover } from './StatePopover'; import { Popover } from './Popover'; @@ -73,6 +76,10 @@ export interface RowOptions { * lane (Review's queue / Needs QA) — rendered in the state popover, never * inline on the card. */ rankReason?: (p: DerivedPull) => string | null; + /** the two "In your code regions" lanes (Review, Team) set this true on + * their own rows: the lane header already says "this is your region," so + * the region mark would be redundant on every card inside it. */ + hideRegionMark?: boolean; } /** @@ -246,31 +253,12 @@ function RowDetails({ flags }: { flags: Flag[] }) { ); } -const ICON_COPY = - 'M5 1a1 1 0 0 0-1 1v1H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1a1 1 0 0 0 1-1V4.4L11.6 1H5Zm6 11v1H3V4h1v7a1 1 0 0 0 1 1h6Zm2-2H5V2h5v3h3v5Z'; -const ICON_SNOOZE = - 'M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 1.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Zm-.75 2v4.06l3.1 1.86.77-1.28-2.37-1.42V4.5h-1.5Z'; -const ICON_REFRESH = - 'M8 3a5 5 0 1 0 4.9 6h-1.55A3.5 3.5 0 1 1 8 4.5c.97 0 1.85.4 2.48 1.02L8.5 7.5H13V3l-1.46 1.46A4.98 4.98 0 0 0 8 3Z'; -const ICON_KEBAB = - 'M8 4.4a1.4 1.4 0 1 1 0-2.8 1.4 1.4 0 0 1 0 2.8Zm0 5a1.4 1.4 0 1 1 0-2.8 1.4 1.4 0 0 1 0 2.8Zm0 5a1.4 1.4 0 1 1 0-2.8 1.4 1.4 0 0 1 0 2.8Z'; -// A raised hand: "I've got this one" — the claim toggle, like putting your -// hand up to take the review. Heroicons' solid hand-raised (24-unit viewBox, -// so this icon renders with box={24}). Brand-tinted when the claim is yours -// (below), muted otherwise, same glyph either way. -const ICON_HAND = - 'M10.5 1.875a1.125 1.125 0 0 1 2.25 0v8.219c.517.162 1.02.382 1.5.659V3.375a1.125 1.125 0 0 1 2.25 0v10.937a4.505 4.505 0 0 0-3.25 2.373 8.963 8.963 0 0 1 4-.935A.75.75 0 0 0 18 15v-2.266a3.368 3.368 0 0 1 .988-2.37 1.125 1.125 0 0 1 1.591 1.59 1.118 1.118 0 0 0-.329.79v3.006h.005a6 6 0 0 1-1.752 4.007l-1.736 1.736a6 6 0 0 1-4.242 1.757H10.5a7.5 7.5 0 0 1-7.5-7.5V6.375a1.125 1.125 0 0 1 2.25 0v5.519c.46-.452.965-.832 1.5-1.141V3.375a1.125 1.125 0 0 1 2.25 0v6.526c.495-.1.997-.151 1.5-.151V1.875Z'; - -function ActionIcon({ d, spin, box = 16 }: { d: string; spin?: boolean; box?: number }) { - return ( - - - - ); +/** "a, b, and c" for the region-match popover sentence — plain-English list, + * no Oxford-comma debate for the two-item case. */ +function joinAnd(items: string[]): string { + if (items.length <= 1) return items.join(''); + if (items.length === 2) return `${items[0]} and ${items[1]}`; + return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`; } /** @@ -335,7 +323,7 @@ function RowActions({ className="hit pressable rounded border-0 bg-transparent px-1 text-xs text-brand" onClick={a.release} > - + )} ) : ( - + )} {/* never offered on your own pull — you don't review yourself, but the slot's width is reserved so the rail (and the age @@ -399,7 +387,7 @@ function RowActions({ className={btn} onClick={a.claim} > - + )} @@ -407,19 +395,6 @@ function RowActions({ ); } -/** - * The star/mute glyph every kebab star item shares: a fixed-width slot the - * same size as the SVG action icons, so the menu's leading column still - * lines up even though these two rows use a text glyph instead of a path. - */ -function StarGlyph({ on }: { on: boolean }) { - return ( - - {on ? '★' : '☆'} - - ); -} - /** * The touch-only row menu: below 720px the hover cluster (RowActions) doesn't * exist, so every row keeps a quiet, always-visible kebab that opens a @@ -458,12 +433,12 @@ function RowActionsKebab({ pull, claim }: { pull: DerivedPull; claim: Claim | nu aria-label="row actions" className="hit pressable -my-2 rounded border-0 bg-transparent px-0.5 py-2 text-ink-3 hover:text-brand" > - + )} > {/* never offered on your own pull — you don't review yourself */} @@ -498,7 +473,7 @@ function RowActionsKebab({ pull, claim }: { pull: DerivedPull; claim: Claim | nu : "claim this review, flags that you're reading it" } > - + {claimedByMe ? 'Release claim' : 'Claim review'} )} @@ -514,7 +489,7 @@ function RowActionsKebab({ pull, claim }: { pull: DerivedPull; claim: Claim | nu : `mark ${repoLabel} a primary repo, leads your review queue` } > - + {isPrimaryRepo ? `Unstar ${repoLabel}` : `Star ${repoLabel}`} {/* never offered for your own pulls — you can't mute yourself off @@ -761,11 +736,10 @@ function RowImpl({ avatarBadge={ starredAuthor && ( - ★ + ) } @@ -792,20 +766,37 @@ function RowImpl({ > - {regions.length > 0 && ( - // a neutral chip, only the ◆ in brand: region-match is soft - // personalization, not urgency — a filled brand chip diluted - // "blue = your move" (color audit) - 1 ? 'regions' : 'region'}: ${regions.join(', ')}`} - className="pd-chip-optional chip-in inline-flex flex-none items-center gap-1 rounded border border-line px-1.5 py-0.5 text-[11px] leading-none font-medium text-ink-2" + {regions.length > 0 && !opts.hideRegionMark && ( + // a bare hover-door mark, not a chip: region-match is soft + // personalization, not urgency, and inside the "In your code + // regions" lanes it would only repeat what the lane header + // already says — a filled brand chip diluted "blue = your + // move" (color audit), and naming every region inline + // out-weighed a 10px glyph (2026-07 icon pass) + ( + + )} > - - ◆ + + Touches {joinAnd(regions)},{' '} + {regions.length > 1 ? 'code regions' : 'a code region'} you flagged in + Settings. - {regions.slice(0, 2).join(', ')} - {regions.length > 2 && ` +${regions.length - 2}`} - + )} {/* the age numeral floats right, capping the baseline track — diff --git a/frontend-v2/src/components/SavedFiltersPanel.tsx b/frontend-v2/src/components/SavedFiltersPanel.tsx index 75f1f865..480b7471 100644 --- a/frontend-v2/src/components/SavedFiltersPanel.tsx +++ b/frontend-v2/src/components/SavedFiltersPanel.tsx @@ -7,6 +7,7 @@ import { type KeyboardEvent, type RefObject, } from 'react'; +import { Bookmark, ChevronDown, Search, X } from 'lucide-react'; import { applySavedFilter, deleteFilter, @@ -17,6 +18,7 @@ import { type SavedFilter, } from '../model/savedFilters'; import { QuietButton } from './bits'; +import { Icon } from './Icon'; import { Popover } from './Popover'; /** @@ -103,7 +105,7 @@ function SavedFilterRow({ {armed ? ( sure? ) : ( - '✕' + )} @@ -220,13 +222,7 @@ export function SavedFiltersInput({ className="relative ml-auto inline-flex max-w-full grow items-center sm:grow-0" onBlur={onWrapperBlur} > - - - + - - - + Saved - - ▾ - + )} > diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index fd2630e5..04ecc8ec 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; +import { ChevronRight, Settings as SettingsIcon, X } from 'lucide-react'; import { clearStoredPrefs } from '../storage'; import { notificationsSupported, @@ -21,6 +22,7 @@ import { useSettings, } from '../settings'; import { QuietButton, Segmented, Switch } from './bits'; +import { Icon } from './Icon'; import { RepoManagerGroup } from './RepoManager'; import { TeamPickerGroup } from './TeamPicker'; @@ -53,9 +55,7 @@ function Explainer({ summary, children }: { summary: string; children: ReactNode return (
- - ▸ - + {summary}
{children}
@@ -162,15 +162,7 @@ function CodeRegionsGroup() { onClick={() => removeCodeRegion(r)} className="hit pressable rounded p-0.5 text-brand-700/60 hover:text-brand-700" > - - - + @@ -314,9 +306,7 @@ export function Settings({ onClick={() => setOpen(o => !o)} className="pressable inline-flex h-8 w-8 items-center justify-center rounded-lg border border-line bg-surface text-ink-2 hover:text-brand" > - - - + {open && createPortal( @@ -342,7 +332,7 @@ export function Settings({ onClick={() => setOpen(false)} className="pressable inline-flex h-7 w-7 items-center justify-center rounded-lg text-ink-3 hover:bg-muted hover:text-ink" > - ✕ +
diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 746d049b..4f21edd8 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import type { ButtonHTMLAttributes, KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { Check, CircleDot, Star, X } from 'lucide-react'; import { type DerivedPull, headStatuses, @@ -21,6 +22,7 @@ import { signatureUrl, } from '../format'; import { getSettings } from '../settings'; +import { Icon } from './Icon'; import { Popover } from './Popover'; export const STATUS_LABEL: Record = { @@ -43,7 +45,9 @@ export const STATUS_LABEL: Record = { export const STATUS_DOT: Record = { ready: 'var(--ok)', ci_pending: 'var(--slate)', - needs_recr: 'var(--brand)', + // a lapsed stamp is owed work, amber by doctrine — reaches the screen via + // Stats' status bar + needs_recr: 'var(--warn)', needs_qa: 'var(--violet)', needs_cr: 'var(--ink-3)', dev_block: 'var(--warn)', @@ -235,6 +239,16 @@ export function Avatar({ ); } +/** + * The one star mark every "primary repo" / "starred person" toggle shares + * (Row's kebab menu, RepoFilter, PeopleFilter, RepoManager): filled when on, + * outline when off, same lucide glyph everywhere instead of five hand-rolled + * ★/☆ copies. + */ +export function StarMark({ on, size = 14 }: { on: boolean; size?: number }) { + return ; +} + export const WEIGHT_WORD: Record = { XS: 'very light', S: 'light', @@ -264,12 +278,12 @@ export function DiffSize({ additions, deletions }: { additions: number; deletion const CI_STATE_META: Record< CommitStatus['data']['state'], - { icon: string; color: string; word: string } + { icon: typeof Check; color: string; word: string } > = { - success: { icon: '✓', color: 'var(--ok)', word: 'passed' }, - failure: { icon: '✗', color: 'var(--bad)', word: 'failed' }, - error: { icon: '✗', color: 'var(--bad)', word: 'errored' }, - pending: { icon: '•', color: 'var(--slate)', word: 'running' }, + success: { icon: Check, color: 'var(--ok)', word: 'passed' }, + failure: { icon: X, color: 'var(--bad)', word: 'failed' }, + error: { icon: X, color: 'var(--bad)', word: 'errored' }, + pending: { icon: CircleDot, color: 'var(--slate)', word: 'running' }, }; const isRedCheck = (s: CommitStatus) => s.data.state === 'failure' || s.data.state === 'error'; @@ -379,12 +393,8 @@ export function CiStatus({ pull }: { pull: DerivedPull }) { const dur = ciDuration(c); const inner = ( <> - - {meta.icon} + + {c.data.context} {meta.word} diff --git a/frontend-v2/src/components/filters/DraftsFilter.tsx b/frontend-v2/src/components/filters/DraftsFilter.tsx index a70bf3e5..6d8d2dff 100644 --- a/frontend-v2/src/components/filters/DraftsFilter.tsx +++ b/frontend-v2/src/components/filters/DraftsFilter.tsx @@ -1,4 +1,6 @@ +import { ChevronDown, Pencil } from 'lucide-react'; import { Segmented } from '../bits'; +import { Icon } from '../Icon'; import { Popover } from '../Popover'; /** @@ -40,17 +42,9 @@ export function DraftsFilter({ title={summary} aria-label={`drafts filter: ${summary}`} > - - - + {summary} - - ▾ - + )} > diff --git a/frontend-v2/src/components/filters/FilterChips.tsx b/frontend-v2/src/components/filters/FilterChips.tsx index f5c20df8..83fd1c77 100644 --- a/frontend-v2/src/components/filters/FilterChips.tsx +++ b/frontend-v2/src/components/filters/FilterChips.tsx @@ -1,6 +1,8 @@ +import { X } from 'lucide-react'; import type { ActionStateKey } from '../../model/actions'; import { CRYO_KEY } from '../../model/visibility'; import { useSettings } from '../../settings'; +import { Icon } from '../Icon'; interface Pill { key: string; @@ -197,7 +199,7 @@ export function FilterChips({ aria-label={`clear ${p.label}`} className="hit pressable rounded px-1 text-ink-3 hover:text-brand" > - ✕ + ))} diff --git a/frontend-v2/src/components/filters/PeopleFilter.tsx b/frontend-v2/src/components/filters/PeopleFilter.tsx index 2b942450..fe1d7ea3 100644 --- a/frontend-v2/src/components/filters/PeopleFilter.tsx +++ b/frontend-v2/src/components/filters/PeopleFilter.tsx @@ -1,9 +1,11 @@ import { useState } from 'react'; +import { ChevronDown, Users } from 'lucide-react'; import type { DerivedPull } from '../../model/status'; import { toggleMutedPerson, toggleStarredPerson, useSettings } from '../../settings'; import { usePulldasher } from '../../store'; import type { Team } from '../../types'; -import { Avatar, QuietButton } from '../bits'; +import { Avatar, QuietButton, StarMark } from '../bits'; +import { Icon } from '../Icon'; import { Popover } from '../Popover'; import { FilterRow, FilterSearch, OnlyButton } from './shared'; @@ -90,17 +92,9 @@ export function PeopleFilter({ title={summary} aria-label={`people filter: ${summary}`} > - - - + {summary} - - ▾ - + )} > @@ -167,7 +161,7 @@ export function PeopleFilter({ : `star ${login} to float their pulls to the front of your queues` } > - {isStarred ? '★' : '☆'} + {login !== me && ( toggleMutedPerson(login, true)}> diff --git a/frontend-v2/src/components/filters/RepoFilter.tsx b/frontend-v2/src/components/filters/RepoFilter.tsx index db53d391..c6cad431 100644 --- a/frontend-v2/src/components/filters/RepoFilter.tsx +++ b/frontend-v2/src/components/filters/RepoFilter.tsx @@ -1,8 +1,10 @@ import { useState } from 'react'; +import { ChevronDown, Tag } from 'lucide-react'; import { CRYO_KEY, repoState } from '../../model/visibility'; import { shortRepo } from '../../format'; import { setRepoPref, togglePrimaryRepo, useSettings } from '../../settings'; -import { QuietButton } from '../bits'; +import { QuietButton, StarMark } from '../bits'; +import { Icon } from '../Icon'; import { Popover } from '../Popover'; import { FilterSearch, OnlyButton } from './shared'; @@ -111,7 +113,7 @@ export function RepoFilter({ } title={isPrimary ? 'a repo you review' : 'mark a repo you review'} > - {isPrimary ? '★' : '☆'} + setRepoPref(name, 'mute')}>Mute
@@ -174,17 +176,9 @@ export function RepoFilter({ title={summary} aria-label={`repos filter: ${summary}`} > - - - + {summary} - - ▾ - + )} > diff --git a/frontend-v2/src/components/filters/StateFilter.tsx b/frontend-v2/src/components/filters/StateFilter.tsx index fd4a08bf..9b7821ba 100644 --- a/frontend-v2/src/components/filters/StateFilter.tsx +++ b/frontend-v2/src/components/filters/StateFilter.tsx @@ -1,7 +1,9 @@ +import { ChevronDown, ClipboardList } from 'lucide-react'; import type { ActionStateKey } from '../../model/actions'; import { actionState } from '../../model/actions'; import type { DerivedPull } from '../../model/status'; import { usePulldasher } from '../../store'; +import { Icon } from '../Icon'; import { Popover } from '../Popover'; import { FilterRow, OnlyButton } from './shared'; @@ -76,17 +78,9 @@ export function StateFilter({ title={summary} aria-label={`state filter: ${summary}`} > - - - + {summary} - - ▾ - + )} > diff --git a/frontend-v2/src/components/filters/WeightFilter.tsx b/frontend-v2/src/components/filters/WeightFilter.tsx index 5c452c9d..fe12bb65 100644 --- a/frontend-v2/src/components/filters/WeightFilter.tsx +++ b/frontend-v2/src/components/filters/WeightFilter.tsx @@ -1,5 +1,7 @@ +import { ChevronDown, Weight as WeightIcon } from 'lucide-react'; import type { DerivedPull } from '../../model/status'; import { weightFilterKey } from '../../model/status'; +import { Icon } from '../Icon'; import { Popover } from '../Popover'; import { FilterRow, OnlyButton } from './shared'; @@ -63,17 +65,9 @@ export function WeightFilter({ title={summary} aria-label={`weight filter: ${summary}`} > - - - + {summary} - - ▾ - + )} > diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 7f993d6a..0558ba1f 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -102,6 +102,7 @@ --color-ok: var(--ok); --color-warn: var(--warn); --color-bad: var(--bad); + --color-slate: var(--slate); } body { @@ -343,18 +344,6 @@ label:has(> input:not(:disabled)) { padding-inline: 4px; } } -/* below 360px, hide the one optional chip — the code-region tag (Row.tsx - applies .pd-chip-optional to it alone; the +/- diff size isn't inline on - cards at all, it lives in the weight popover) — so the title and rail keep - the width in a genuinely cramped column (Classic's tightest 3-4-up columns, - or a phone). Its content stays reachable via the row's title attribute and - the regions lane, so nothing is truly lost. */ -@container (max-width: 360px) { - .pd-chip-optional { - display: none; - } -} - /* whole-card click target: the title link stretches an invisible layer over its positioned row, so clicking anywhere opens the PR. Genuinely interactive children (avatar, ledger, actions) sit above it via .pd-raise. */ diff --git a/frontend-v2/src/toasts.tsx b/frontend-v2/src/toasts.tsx index 1c2e31ac..42220926 100644 --- a/frontend-v2/src/toasts.tsx +++ b/frontend-v2/src/toasts.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { X } from 'lucide-react'; import { githubUrl, rowDomId } from './format'; +import { Icon } from './components/Icon'; import { type CheerBaseline, type CheerToast, @@ -581,15 +583,7 @@ function ToastCard({ toast, onDismiss }: { toast: LiveToast; onDismiss: (id: num onDismiss(toast.id); }} > - - - + {toast.shimmer && ( // the sweep gets its own clipping layer instead of overflow-hidden diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index cca37cfb..80b2c8db 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -438,6 +438,9 @@ export function Review({ cap={8} opts={{ ...opts, + // the lane heading already says "this is your region," so + // the row-level region mark would just repeat it + hideRegionMark: true, rankReason: p => { const r = matchedRegions(p, codeRegions); return r.length diff --git a/frontend-v2/src/views/Team.tsx b/frontend-v2/src/views/Team.tsx index 5984f34e..e3dbbd18 100644 --- a/frontend-v2/src/views/Team.tsx +++ b/frontend-v2/src/views/Team.tsx @@ -150,7 +150,9 @@ export function Team({ } pulls={regionMatches} cap={8} - opts={opts} + // the lane heading already says "this is your region," so the + // row-level region mark would just repeat it + opts={{ ...opts, hideRegionMark: true }} /> )}

- Lightest first, so a short break fits a review. A PR that needs just one - more approval jumps up (yours would finish it), and PRs move up as they - wait. PRs the author is still actively pushing to sink to the bottom. + Lightest first, so a short break fits a review. A PR that needs just one more + approval jumps up (yours would finish it), and PRs move up as they wait. PRs + the author is still actively pushing to sink to the bottom.

} From af826529b078728b1dc98e746c591b9fb14aaaa4 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 11:36:53 -0700 Subject: [PATCH 256/381] Per-view lane length; settings move to where they act Two settings changes from the owner's research pass: Lane length can now differ per view. Every lane already authored its own default cap and funneled through one laneShown formula, so the global setting gains a per-lens override map (laneCapByLens) resolved where rowOpts is built. The Settings field keeps the global default and grows a 'Different length per view' disclosure: Classic can show everything while Review stays short, which is the point. Three prime fields leave the panel: - The drafts and parked-PRs defaults move into the filters they configure. Each filter popover shows 'Make this my default' when your session choice differs from the saved default, so the durable setting lives on the surface where its effect is visible. - 'Age counts as rotting' collapses into the warn threshold: the heaviest text tier now follows automatically at 2.5x 'Age line appears'. Two knobs on one concept was one too many. - 'Mark the board seen after' folds into an Advanced disclosure; real but rarely touched, it no longer spends prime panel space. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 12 +- frontend-v2/src/components/Settings.tsx | 147 ++++++++++-------- .../src/components/filters/DraftsFilter.tsx | 10 +- .../src/components/filters/RepoFilter.tsx | 52 ++++--- frontend-v2/src/settings.ts | 27 +++- frontend-v2/src/views/Stats.tsx | 11 +- 6 files changed, 158 insertions(+), 101 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index dff9f331..8cc031b7 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -683,7 +683,10 @@ export function App() { // full track, everything else is a fraction of it — "how long has this // waited, relative to what waiting looks like here" const maxAgeDays = useMemo(() => pulls.reduce((m, p) => Math.max(m, p.ageDays), 1), [pulls]); - // stable identity so memo(Row) can skip untouched rows on socket bursts + // stable identity so memo(Row) can skip untouched rows on socket bursts. + // laneCap resolves per-lens (falling back to the global default) — safe to + // read the current `lens` here because only one lens's view ever mounts at + // a time, so rowOpts never has to carry more than one lens' cap at once. const rowOpts: RowOptions = useMemo( () => ({ me, @@ -693,9 +696,9 @@ export function App() { onWeightToggle, maxAgeDays, ageWarnDays: settings.ageWarnDays, - ageRotDays: settings.ageRotDays, + ageRotDays: Math.round(settings.ageWarnDays * 2.5), compact: settings.density === 'compact', - laneCap: settings.laneCap, + laneCap: settings.laneCapByLens[lens] ?? settings.laneCap, parentOf, pools, turns, @@ -708,9 +711,10 @@ export function App() { onWeightToggle, maxAgeDays, settings.ageWarnDays, - settings.ageRotDays, settings.density, settings.laneCap, + settings.laneCapByLens, + lens, parentOf, pools, turns, diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 04ecc8ec..4a93644e 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -14,6 +14,7 @@ import { clearSnoozes, markAllSeen, refreshAll, usePulldasher } from '../store'; import { addCodeRegion, removeCodeRegion, + setLaneCapForLens, type Settings as SettingsShape, setRepoPref, setSettings, @@ -36,6 +37,43 @@ const LENS_OPTIONS: [string, string][] = [ ['stats', 'Stats'], ]; +// Stats has no lanes, so it's left out of the per-lens lane-length list +// (unlike LENS_OPTIONS above, which covers every tab including Stats). +const LANE_CAP_LENS_OPTIONS: [string, string][] = [ + ['review', 'Review'], + ['mine', 'My work'], + ['team', 'Team'], + ['people', 'People'], + ['classic', 'Classic'], + ['ci', 'CI'], +]; + +const LANE_CAP_OPTIONS: [string, string][] = [ + ['default', 'Default'], + ['10', '10'], + ['25', '25'], + ['50', '50'], + ['0', 'No cap'], +]; + +/** One lens' lane-length override row, inside the "Different length per view" + * disclosure. "Default" clears the override so the lens falls back to the + * global Lane length above. */ +function LaneCapByLensRow({ lensId, label }: { lensId: string; label: string }) { + const cap = useSettings().laneCapByLens[lensId]; + return ( +
+ {label} + setLaneCapForLens(lensId, v === 'default' ? null : Number(v))} + /> +
+ ); +} + /** A labelled group of controls inside the panel. */ function Group({ title, children }: { title: string; children: ReactNode }) { return ( @@ -404,30 +442,28 @@ export function Settings({ onChange={v => set({ laneCap: Number(v) })} /> + + + Classic can show everything while Review stays short. + +
+ {LANE_CAP_LENS_OPTIONS.map(([id, label]) => ( + + ))} +
+
set({ ageWarnDays })} /> - - set({ ageRotDays })} - /> - set({ claimWarnMins: Number(v) })} /> - - set({ draftsMode })} - /> - - - set({ showCryo: v === 'show' })} - /> - - - set({ seenAfterSecs })} - /> - -
- { - markAllSeen(); - setSeenNote(true); - setTimeout(() => setSeenNote(false), 1600); - }} + + - Mark everything as seen - - - {seenNote ? 'done' : ''} - -
+ set({ seenAfterSecs })} + /> +
+
+ { + markAllSeen(); + setSeenNote(true); + setTimeout(() => setSeenNote(false), 1600); + }} + > + Mark everything as seen + + + {seenNote ? 'done' : ''} + +
+
, diff --git a/frontend-v2/src/components/filters/DraftsFilter.tsx b/frontend-v2/src/components/filters/DraftsFilter.tsx index 6d8d2dff..5a9fdc29 100644 --- a/frontend-v2/src/components/filters/DraftsFilter.tsx +++ b/frontend-v2/src/components/filters/DraftsFilter.tsx @@ -1,5 +1,6 @@ import { ChevronDown, Pencil } from 'lucide-react'; -import { Segmented } from '../bits'; +import { setSettings } from '../../settings'; +import { QuietButton, Segmented } from '../bits'; import { Icon } from '../Icon'; import { Popover } from '../Popover'; @@ -60,6 +61,13 @@ export function DraftsFilter({ onChange={setDraftsMode} />
+ {draftsMode !== defaultMode && ( +
+ setSettings({ draftsMode })}> + Make this my default + +
+ )}

Your own drafts always show. “All” adds everyone else’s.

diff --git a/frontend-v2/src/components/filters/RepoFilter.tsx b/frontend-v2/src/components/filters/RepoFilter.tsx index c6cad431..5759d07d 100644 --- a/frontend-v2/src/components/filters/RepoFilter.tsx +++ b/frontend-v2/src/components/filters/RepoFilter.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { ChevronDown, Tag } from 'lucide-react'; import { CRYO_KEY, repoState } from '../../model/visibility'; import { shortRepo } from '../../format'; -import { setRepoPref, togglePrimaryRepo, useSettings } from '../../settings'; +import { setRepoPref, setSettings, togglePrimaryRepo, useSettings } from '../../settings'; import { QuietButton, StarMark } from '../bits'; import { Icon } from '../Icon'; import { Popover } from '../Popover'; @@ -215,24 +215,38 @@ export function RepoFilter({
No repos match.
)} - {cryoCount > 0 && ( -
-
- This session -
- -
- )} + {cryoCount > 0 && + (() => { + const cryoSessionOn = settings.showCryo || reveal.includes(CRYO_KEY) || showAll; + return ( +
+
+ This session +
+ + {cryoSessionOn !== settings.showCryo && ( +
+ setSettings({ showCryo: cryoSessionOn })} + > + Make this my default + +
+ )} +
+ ); + })()}
); diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index c779adaf..d68b78d6 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -1,4 +1,4 @@ -import { ROT_DAYS, STARVE_DAYS } from './model/status'; +import { STARVE_DAYS } from './model/status'; import { createPersistentStore } from './storage'; /** @@ -14,10 +14,11 @@ export interface Settings { density: 'comfortable' | 'compact'; /** which lens a bare / URL opens */ defaultLens: string; - /** a PR's age turns amber at this many days (display only) */ + /** a PR's age turns amber at this many days (display only). The heaviest + * text tier (red) follows automatically at ageWarnDays * 2.5 — callers + * derive that rotDays for AgeStamp/AgeBaseline (app.tsx, views/Stats.tsx) + * instead of storing it separately. */ ageWarnDays: number; - /** and red at this many (display only) */ - ageRotDays: number; /** seconds of attention before leaving stamps "last seen" (the glance guard) */ seenAfterSecs: number; /** per-repo override of the org baseline: 'mute' hides a shown repo, @@ -46,8 +47,14 @@ export interface Settings { * mute-list, not an allow-list, so a newly added kind defaults to on. Only * bites when `cheers` is on — the master switch still gates the whole lot. */ mutedCheers: string[]; - /** rows a lane shows before folding into "+N more"; 0 = no cap (show all) */ + /** rows a lane shows before folding into "+N more"; 0 = no cap (show all). + * The global default; a lens absent from laneCapByLens uses this. */ laneCap: number; + /** per-lens override of laneCap, keyed by the lens id (app.tsx's Lens + * type minus 'stats', which has no lanes). An absent key means "use the + * global laneCap" — old saved settings simply lack this field, so they + * fall back to the object default below. */ + laneCapByLens: Record; /** teams that self-review (iFixit) don't gate on CR, so lining up QA is the * real stall: surface "Find a QA-er" on your own PR as a home to-do, not a * My-work afterthought. Off leaves getting QA in My work only. */ @@ -92,7 +99,6 @@ export const DEFAULT_SETTINGS: Settings = { density: 'comfortable', defaultLens: 'review', ageWarnDays: STARVE_DAYS, - ageRotDays: ROT_DAYS, seenAfterSecs: 45, repoPrefs: {}, draftsMode: 'mine', @@ -104,6 +110,7 @@ export const DEFAULT_SETTINGS: Settings = { notifyBadge: 'count', mutedCheers: [], laneCap: 10, + laneCapByLens: {}, selfReview: true, primaryRepos: [], myTeam: [], @@ -134,6 +141,14 @@ export function setRepoPref(repo: string, pref: 'mute' | 'show' | null) { setSettings({ repoPrefs: next }); } +/** Set or clear one lens's lane-length override. null follows the global laneCap. */ +export function setLaneCapForLens(lens: string, cap: number | null) { + const next = { ...store.get().laneCapByLens }; + if (cap == null) delete next[lens]; + else next[lens] = cap; + setSettings({ laneCapByLens: next }); +} + /** Add or remove a repo from your primary (actively-reviewed) set. */ export function togglePrimaryRepo(repo: string, primary: boolean) { const cur = store.get().primaryRepos; diff --git a/frontend-v2/src/views/Stats.tsx b/frontend-v2/src/views/Stats.tsx index 9b687338..e798a868 100644 --- a/frontend-v2/src/views/Stats.tsx +++ b/frontend-v2/src/views/Stats.tsx @@ -104,6 +104,9 @@ export function Stats({ const giveTake = useMemo(() => reciprocity(pulls, closed), [pulls, closed]); const starved = useMemo(() => crStarvation(pulls), [pulls]); const settings = useSettings(); + // the heaviest text tier follows the warn threshold automatically, same + // derivation as app.tsx's rowOpts — see ageWarnDays in settings.ts + const ageRotDays = Math.round(settings.ageWarnDays * 2.5); // trends — server-side history the live socket payload doesn't carry (it // only ships open pulls + 14 days of closed ones); null while loading or on // any fetch failure, which hides the whole band below. @@ -136,11 +139,7 @@ export function Stats({ - + @@ -155,7 +154,7 @@ export function Stats({ me={me} onPerson={onPerson} warnDays={settings.ageWarnDays} - rotDays={settings.ageRotDays} + rotDays={ageRotDays} /> Date: Wed, 22 Jul 2026 12:03:11 -0700 Subject: [PATCH 257/381] Dissolve the settings panel; controls live where they act MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel had grown into 2,846px of scroll in a 360px column: 31 fields, 102 buttons, 8 groups, 20% visible at once. The audit found four kinds of content sharing one container at equal weight, and most of it was never a setting: - Notification preferences (553px) now live behind the bell, where nudges actually arrive: a gear in the Recent-nudges header flips the popover to a Nudge settings view (master toggle, per-kind list, dwell, badge, desktop + sound) and a back arrow returns. The shared Field/Explainer primitives moved to SettingsBits.tsx so both surfaces render identically. - Identity data (880px of embedded team and repo pickers) left entirely. The Team view and the Repos filter were already the real editors; the panel now says so in two plain sentences ('Edited on the board'), with an Open Team view button that switches the lens and closes the panel in one click. - Data verbs and the mark-seen timer merged into one Advanced fold at the bottom, destructive action last. - What remains: Look (theme, density, default view), Board (six fields), Code regions. 1,244px total, everything reachable in two screens instead of eight. The remaining gap to a true one-screen panel is the Board hints, which are junior-dev explanatory copy we keep on purpose. Two bugs fixed along the way: the panel's sticky header had no z-index, so positioned content (buttons, avatars, stars) painted over it while scrolling, worst on mobile — it holds z-10 now, the same guard the board's lane headers use. And the bell's gear/back buttons needed stopPropagation: swapping the header DOM in the same click detached the event target before the click-away listener ran, which read as an outside click and closed the popover. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/app.tsx | 7 +- .../src/components/NotificationPanel.tsx | 393 +++++++++++--- frontend-v2/src/components/Settings.tsx | 497 +++++------------- frontend-v2/src/components/SettingsBits.tsx | 85 +++ 4 files changed, 552 insertions(+), 430 deletions(-) create mode 100644 frontend-v2/src/components/SettingsBits.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 8cc031b7..d116b306 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -900,12 +900,7 @@ export function App() { - + setLens('team')} />
{/* min-w-0 + overflow-x-auto (no-scrollbar in styles.css) diff --git a/frontend-v2/src/components/NotificationPanel.tsx b/frontend-v2/src/components/NotificationPanel.tsx index 2417bddb..faee733e 100644 --- a/frontend-v2/src/components/NotificationPanel.tsx +++ b/frontend-v2/src/components/NotificationPanel.tsx @@ -1,10 +1,25 @@ import { useState } from 'react'; -import { Bell, X } from 'lucide-react'; +import { ArrowLeft, Bell, Settings as SettingsIcon, X } from 'lucide-react'; import { ago, githubUrl } from '../format'; -import { useSettings } from '../settings'; +import { type CheerGroup, CONFIGURABLE_TOASTS } from '../model/cheers'; +import { + type Settings as SettingsShape, + setSettings, + toggleCheerKind, + useSettings, +} from '../settings'; +import { + notificationsSupported, + notifyPermission, + requestNotifyPermission, + testNotification, + unlockSound, +} from '../notifications'; import type { ToastRecord } from '../toasts'; +import { QuietButton, Segmented, Switch } from './bits'; import { Icon } from './Icon'; import { onOpen, Popover } from './Popover'; +import { Explainer, Field } from './SettingsBits'; /** Tone → medallion tint, matching the toast card's own vocabulary so a nudge * reads the same in the panel as it did when it flashed. */ @@ -14,11 +29,210 @@ const MEDALLION: Record = { nag: 'bg-secondary text-ink-2', }; +const CHEER_GROUPS: { group: CheerGroup; title: string }[] = [ + { group: 'reward', title: 'Rewards' }, + { group: 'nudge', title: 'Nudges' }, + { group: 'author', title: 'Your PRs' }, +]; + +/** The per-kind list: every cheer/nudge, grouped, each a one-line explanation + * with its own switch. The master "Cheers & nudges" toggle still gates the + * whole system; these pick which kinds fire when it's on, so they read as + * disabled while it's off. */ +function CheerToggles({ disabled }: { disabled: boolean }) { + const muted = new Set(useSettings().mutedCheers); + return ( +
+ {CHEER_GROUPS.map(({ group, title }) => ( +
+ + {title} + + {CONFIGURABLE_TOASTS.filter(c => c.group === group).map(c => ( +
+ + {c.label} + {c.hint} + + toggleCheerKind(c.kind, next)} + /> +
+ ))} +
+ ))} +
+ ); +} + +/** + * The "Nudge settings" view: everything the Settings panel's old + * Notifications group held, moved here so notification preferences live + * behind the bell they govern instead of a separate drawer. Renders through + * the same Field/Explainer/Segmented primitives Settings.tsx uses, so the + * controls read identically in both places. + */ +function NudgeSettingsView() { + const s = useSettings(); + const [perm, setPerm] = useState(notifyPermission()); + const set = (patch: Partial) => setSettings(patch); + + const toggleNotify = async (on: boolean) => { + if (!on) { + setSettings({ notify: false }); + return; + } + let p = notifyPermission(); + if (p === 'default') p = await requestNotifyPermission(); + setPerm(p); + // this handler runs from the toggle click, so unlock audio now while we + // still have the gesture, in case Sound is already on + if (p === 'granted') unlockSound(); + setSettings({ notify: p === 'granted' }); + }; + + return ( +
+ + set({ cheers })} /> + + + + + + set({ cheerDwell })} /> + + + set({ notifyBadge })} /> + + {notificationsSupported ? ( + <> + + void toggleNotify(v)} + /> + + {perm === 'denied' && ( + + Blocked in your browser settings; allow notifications for this site to turn + them on. + + )} + + { + // a toggle click is the gesture that unlocks audio + if (notifySound) unlockSound(); + set({ notifySound }); + }} + /> + + {s.notify && perm === 'granted' && ( + testNotification()}> + Send a test notification + + )} + + ) : ( + + This browser doesn’t support desktop notifications. + + )} +
+ ); +} + +// Small typed wrappers around the house Segmented control, so each field +// above stays a one-liner instead of re-spelling its options inline. +function SegmentedOnOff({ + value, + onChange, +}: { + value: boolean; + onChange: (next: boolean) => void; +}) { + return ( + onChange(v === 'on')} + /> + ); +} + +function DwellSegmented({ + value, + onChange, +}: { + value: number | 'sticky'; + onChange: (next: number | 'sticky') => void; +}) { + return ( + onChange(v === 'sticky' ? 'sticky' : Number(v))} + /> + ); +} + +function BadgeSegmented({ + value, + onChange, +}: { + value: 'count' | 'dot' | 'none'; + onChange: (next: 'count' | 'dot' | 'none') => void; +}) { + return ( + + ); +} + /** * The recent-nudges panel: toasts are deliberately non-sticky, so this is * where a nudge you missed (or dismissed) stays recoverable — the same list * the toast stack fired, newest first, each still linking its PR. A brand dot - * on the bell counts what's landed since you last opened it. + * on the bell counts what's landed since you last opened it. The header row's + * gear flips this same popover into a "Nudge settings" view (back arrow to + * return) holding every notification preference, so they live behind the + * bell they govern instead of a separate Settings drawer. */ export function NotificationPanel({ records, @@ -34,24 +248,33 @@ export function NotificationPanel({ // session-only "last opened" — toasts are session-only too, so there's // nothing to persist. Everything fired after this counts as unseen. const [lastSeen, setLastSeen] = useState(0); + const [view, setView] = useState<'nudges' | 'settings'>('nudges'); const unseen = records.filter(r => r.at > lastSeen).length; const badge = useSettings().notifyBadge; const flag = unseen > 0 && badge !== 'none'; return ( ( )} > -
- Recent nudges - {records.length > 0 && ( - - )} -
- {records.length === 0 ? ( -
No nudges yet.
+ {view === 'settings' ? ( + <> +
+ + Nudge settings +
+
+ +
+ ) : ( -
+ )} +
+ {records.length === 0 ? ( +
No nudges yet.
+ ) : ( +
    + {records.map(r => ( +
  • + + {r.toast.icon} + + + + + {r.toast.title} + + + {ago(r.at / 1000)} + + + {r.toast.pull && ( + + #{r.toast.pull.number} + {r.toast.pull.title ? ` ${r.toast.pull.title}` : ''} + + )} + {r.toast.body && ( + {r.toast.body} + )} + + +
  • + ))} +
+ )} + )} ); diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 4a93644e..494673a2 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -1,31 +1,19 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { ChevronRight, Settings as SettingsIcon, X } from 'lucide-react'; +import { Settings as SettingsIcon, X } from 'lucide-react'; import { clearStoredPrefs } from '../storage'; -import { - notificationsSupported, - notifyPermission, - requestNotifyPermission, - testNotification, - unlockSound, -} from '../notifications'; -import { type CheerGroup, CONFIGURABLE_TOASTS } from '../model/cheers'; import { clearSnoozes, markAllSeen, refreshAll, usePulldasher } from '../store'; import { addCodeRegion, removeCodeRegion, setLaneCapForLens, type Settings as SettingsShape, - setRepoPref, setSettings, - toggleCheerKind, - togglePrimaryRepo, useSettings, } from '../settings'; -import { QuietButton, Segmented, Switch } from './bits'; +import { QuietButton, Segmented } from './bits'; import { Icon } from './Icon'; -import { RepoManagerGroup } from './RepoManager'; -import { TeamPickerGroup } from './TeamPicker'; +import { Explainer, Field, Group, NumberField } from './SettingsBits'; const LENS_OPTIONS: [string, string][] = [ ['review', 'Review'], @@ -74,85 +62,6 @@ function LaneCapByLensRow({ lensId, label }: { lensId: string; label: string }) ); } -/** A labelled group of controls inside the panel. */ -function Group({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

- {title} -

-
{children}
-
- ); -} - -/** A collapsible "how it works" note, mirroring the legend's disclosures so the - * explanation reads the same wherever it appears. Closed by default: it's there - * when you go looking, not in the way when you aren't. */ -function Explainer({ summary, children }: { summary: string; children: ReactNode }) { - return ( -
- - - {summary} - -
{children}
-
- ); -} - -const CHEER_GROUPS: { group: CheerGroup; title: string }[] = [ - { group: 'reward', title: 'Rewards' }, - { group: 'nudge', title: 'Nudges' }, - { group: 'author', title: 'Your PRs' }, -]; - -/** The per-kind list: every cheer/nudge, grouped, each a one-line explanation - * with its own switch. The master "Cheers & nudges" toggle still gates the - * whole system; these pick which kinds fire when it's on, so they read as - * disabled while it's off. */ -function CheerToggles({ disabled }: { disabled: boolean }) { - const muted = new Set(useSettings().mutedCheers); - return ( -
- {CHEER_GROUPS.map(({ group, title }) => ( -
- - {title} - - {CONFIGURABLE_TOASTS.filter(c => c.group === group).map(c => ( -
- - {c.label} - {c.hint} - - toggleCheerKind(c.kind, next)} - /> -
- ))} -
- ))} -
- ); -} - -/** One setting: a label (+ optional hint) over its control. */ -function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { - return ( -
-
- {label} - {children} -
- {hint && {hint}} -
- ); -} - /** Free-text editor for the code regions that float matching PRs to the top of * the review queue. Arbitrary strings (not a known set), so it's a plain input * plus removable chips, not a candidate picker. */ @@ -211,51 +120,17 @@ function CodeRegionsGroup() { ); } -/** A small bounded number stepper (days, seconds). */ -function NumberField({ - value, - min, - max, - suffix, - onChange, -}: { - value: number; - min: number; - max: number; - suffix: string; - onChange: (next: number) => void; -}) { - return ( - - { - const n = Number(e.target.value); - if (Number.isFinite(n)) onChange(Math.min(max, Math.max(min, Math.round(n)))); - }} - className="h-8 w-16 rounded-lg border border-line bg-surface px-2 text-right text-[13px] tabular-nums" - /> - {suffix} - - ); -} - export function Settings({ - repos, - orgHidden, snoozedCount, - extraBots, + onGoToTeam, }: { - /** every known repo with its open-PR count, for the repo manager */ - repos: { name: string; count: number }[]; - orgHidden: ReadonlySet; /** pulls currently hidden by a row snooze */ snoozedCount: number; - /** config.json's named bots, so the team picker's suggestions leave them out */ - extraBots: ReadonlySet; + /** switch the board to the Team lens and close this panel — the "Edited + * on the board" pointer line's action. The team picker and repo manager + * embeds are gone (Team view and the header's Repos filter own those + * now), so this is the only cross-surface wiring this panel needs. */ + onGoToTeam: () => void; }) { const [open, setOpen] = useState(false); const s = useSettings(); @@ -265,21 +140,6 @@ export function Settings({ const [seenNote, setSeenNote] = useState(false); const [refreshNote, setRefreshNote] = useState(''); const [armReset, setArmReset] = useState(false); - const [perm, setPerm] = useState(notifyPermission()); - - const toggleNotify = async (on: boolean) => { - if (!on) { - setSettings({ notify: false }); - return; - } - let p = notifyPermission(); - if (p === 'default') p = await requestNotifyPermission(); - setPerm(p); - // this handler runs from the toggle click, so unlock audio now while we - // still have the gesture, in case Sound is already on - if (p === 'granted') unlockSound(); - setSettings({ notify: p === 'granted' }); - }; // open on demand — the code-regions tip's "Set them up" action dispatches // this so a reader can jump straight here from the board @@ -362,7 +222,14 @@ export function Settings({ tabIndex={-1} className="settings-panel absolute top-0 right-0 flex h-full w-[min(360px,100vw)] flex-col overflow-y-auto border-l border-line bg-surface shadow-xl outline-none" > -
+ {/* z-10: children further down the panel (avatars, star + buttons, the code-regions Add button — anything + position:relative via .hit/.pressable) paint above a + sticky header with no z-index of its own once they + scroll under it. The overlay above is already + z-[100], so z-10 in here only has to beat panel + content. */} +
Settings
- + set({ density })} /> - - - - - set({ selfReview: v === 'on' })} - /> - set({ defaultLens })} /> + + + set({ ageWarnDays })} /> + + set({ selfReview: v === 'on' })} + /> + - - - - - set({ cheers: v === 'on' })} - /> - - - - - - - set({ cheerDwell: v === 'sticky' ? 'sticky' : Number(v) }) - } - /> - - - set({ notifyBadge })} - /> - - {notificationsSupported ? ( - <> - - void toggleNotify(v === 'on')} - /> - - {perm === 'denied' && ( - - Blocked in your browser settings; allow notifications for this - site to turn them on. - - )} - - { - // a toggle click is the gesture that unlocks audio - if (v === 'on') unlockSound(); - set({ notifySound: v === 'on' }); - }} - /> - - {s.notify && perm === 'granted' && ( - testNotification()}> - Send a test notification - - )} - - ) : ( - - This browser doesn’t support desktop notifications. + +
+ + Your team: edit it on the Team view. - )} - - - - - - - Re-fetch every open PR from GitHub now, instead of waiting for the next - webhook. The board updates as each one comes back. - -
{ - // live progress (store.refreshProgress) takes over - // from here; the local note only covers the no-op - if (refreshAll() === 0) { - setRefreshNote('nothing to refresh'); - setTimeout(() => setRefreshNote(''), 2500); - } + onGoToTeam(); + setOpen(false); }} > - Refresh all + Open Team view - {/* role=status stays mounted so the announcement fires - when the text lands — a screen reader hears the - confirmation, not just sighted users */} - - {refreshProgress - ? refreshProgress.done === refreshProgress.total - ? `refreshed ${refreshProgress.total}` - : `refreshing ${refreshProgress.done} of ${refreshProgress.total}…` - : refreshNote} -
- - - A snoozed row hides for a day, or until the PR changes. + + Repos: star and mute from the Repos filter in the header. -
- clearSnoozes()} - > - Bring back snoozed - - - {snoozedCount} hidden now - -
- - - Reset every preference on this browser (theme, filters, muted repos, - last-seen marker) back to defaults. This can’t be undone. - -
- -
- +
- - set({ seenAfterSecs })} - /> - + + Re-fetch every open PR from GitHub now, instead of waiting for the + next webhook. The board updates as each one comes back. + +
+ { + // live progress (store.refreshProgress) takes over + // from here; the local note only covers the no-op + if (refreshAll() === 0) { + setRefreshNote('nothing to refresh'); + setTimeout(() => setRefreshNote(''), 2500); + } + }} + > + Refresh all + + {/* role=status stays mounted so the announcement fires + when the text lands — a screen reader hears the + confirmation, not just sighted users */} + + {refreshProgress + ? refreshProgress.done === refreshProgress.total + ? `refreshed ${refreshProgress.total}` + : `refreshing ${refreshProgress.done} of ${refreshProgress.total}…` + : refreshNote} + +
+ + + A snoozed row hides for a day, or until the PR changes. + +
+ clearSnoozes()} + > + Bring back snoozed + + + {snoozedCount} hidden now + +
+ +
+ + set({ seenAfterSecs })} + /> + +
+ + + Reset every preference on this browser (theme, filters, muted repos, + last-seen marker) back to defaults. This can’t be undone. + +
+ +
- +
, document.body diff --git a/frontend-v2/src/components/SettingsBits.tsx b/frontend-v2/src/components/SettingsBits.tsx new file mode 100644 index 00000000..204efdfb --- /dev/null +++ b/frontend-v2/src/components/SettingsBits.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { Icon } from './Icon'; + +/** A labelled group of controls inside a settings surface (the Settings panel + * or the bell's nudge-settings view) — one definition so both render the + * section chrome identically. */ +export function Group({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +/** One setting: a label (+ optional hint) over its control. */ +export function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: ReactNode; +}) { + return ( +
+
+ {label} + {children} +
+ {hint && {hint}} +
+ ); +} + +/** A collapsible "how it works" note, so the same disclosure look and feel + * shows up in the Settings panel and the bell's nudge-settings view. Closed + * by default: it's there when you go looking, not in the way when you aren't. */ +export function Explainer({ summary, children }: { summary: string; children: ReactNode }) { + return ( +
+ + + {summary} + +
{children}
+
+ ); +} + +/** A small bounded number stepper (days, seconds). */ +export function NumberField({ + value, + min, + max, + suffix, + onChange, +}: { + value: number; + min: number; + max: number; + suffix: string; + onChange: (next: number) => void; +}) { + return ( + + { + const n = Number(e.target.value); + if (Number.isFinite(n)) onChange(Math.min(max, Math.max(min, Math.round(n)))); + }} + className="h-8 w-16 rounded-lg border border-line bg-surface px-2 text-right text-[13px] tabular-nums" + /> + {suffix} + + ); +} From ea017826004dcd85f37f471a452d81e10611fd69 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 12:03:58 -0700 Subject: [PATCH 258/381] Remove the dead Settings embeds of the repo and team pickers The settings dissolution left RepoManagerGroup with zero consumers (the Repos filter in the header is a separate implementation and the real editor now) and TeamPickerGroup as an unreferenced wrapper. Delete RepoManager.tsx whole and the wrapper from TeamPicker.tsx; TeamPicker itself stays, the Team view still renders it. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/RepoManager.tsx | 148 --------------------- frontend-v2/src/components/TeamPicker.tsx | 17 +-- 2 files changed, 1 insertion(+), 164 deletions(-) delete mode 100644 frontend-v2/src/components/RepoManager.tsx diff --git a/frontend-v2/src/components/RepoManager.tsx b/frontend-v2/src/components/RepoManager.tsx deleted file mode 100644 index 06dae5a2..00000000 --- a/frontend-v2/src/components/RepoManager.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useState } from 'react'; -import { repoState } from '../model/visibility'; -import { QuietButton, StarMark } from './bits'; -import { FilterSearch } from './filters/shared'; - -export function RepoManagerGroup({ - repos, - orgHidden, - prefs, - onRepoPref, - primaryRepos, - onPrimary, -}: { - /** every known repo with its open-PR count, sorted however the caller likes */ - repos: { name: string; count: number }[]; - /** repo names the org hides by default (read-only baseline) */ - orgHidden: ReadonlySet; - /** the user's per-repo overrides */ - prefs: Record; - /** set or clear one repo's override; null follows the org default */ - onRepoPref: (repo: string, pref: 'mute' | 'show' | null) => void; - /** the repos you actively review (lead your review queue); empty = inferred */ - primaryRepos: string[]; - /** add/remove a repo from your primary set */ - onPrimary: (repo: string, primary: boolean) => void; -}) { - const primary = new Set(primaryRepos); - const [query, setQuery] = useState(''); - - const needle = query.trim().toLowerCase(); - const filtered = - needle === '' ? repos : repos.filter(repo => repo.name.toLowerCase().includes(needle)); - - const muted = filtered.filter(repo => repoState(repo.name, orgHidden, prefs) === 'muted'); - const orgHiddenRepos = filtered.filter( - repo => repoState(repo.name, orgHidden, prefs) === 'org-hidden' - ); - const shown = filtered.filter(repo => repoState(repo.name, orgHidden, prefs) === 'shown'); - - const noMatches = filtered.length === 0; - - return ( -
-

- Repos -

-
- Also editable from the Repos filter in the header. -
- - - {noMatches &&
No repos match.
} - - {muted.length > 0 && ( -
-
- Muted by you ({muted.length}) -
- {muted.map(repo => ( -
- - {repo.name} - - {repo.count} - onRepoPref(repo.name, null)}>Unmute -
- ))} -
- )} - - {orgHiddenRepos.length > 0 && ( -
-
- Hidden by the org ({orgHiddenRepos.length}) -
- {orgHiddenRepos.map(repo => ( -
- - {repo.name} - - {repo.count} - onRepoPref(repo.name, 'show')}> - Show for me - -
- ))} -
- )} - - {shown.length > 0 && ( -
-
- On your board ({shown.length}) -
-
- {primary.size > 0 - ? 'Starred repos lead your review queue; the rest fold into “other repos.”' - : 'Star the repos you review to lead your queue with them. Until you do, they’re inferred from where you’ve authored or stamped.'} -
- {shown.map(repo => { - const isUserRevealed = orgHidden.has(repo.name) && prefs[repo.name] === 'show'; - const isPrimary = primary.has(repo.name); - return ( -
- - - {repo.name} - - {isUserRevealed && ( - org-hidden · showing - )} - {repo.count} - {isUserRevealed && ( - onRepoPref(repo.name, null)}> - Reset - - )} - onRepoPref(repo.name, 'mute')}> - Mute - -
- ); - })} -
- )} -
- ); -} diff --git a/frontend-v2/src/components/TeamPicker.tsx b/frontend-v2/src/components/TeamPicker.tsx index acd6bed3..bdaf9604 100644 --- a/frontend-v2/src/components/TeamPicker.tsx +++ b/frontend-v2/src/components/TeamPicker.tsx @@ -74,7 +74,7 @@ function useCandidates(extraBots: ReadonlySet) { * searchable, capped list of everyone else on the board. Typing a login with * no match on the board still lets you add it — a teammate can have nothing * open right now. Standalone so the Team view's empty state can embed it - * without the Settings drawer's Group chrome (see TeamPickerGroup below). + * without any drawer chrome around it. */ export function TeamPicker({ extraBots = EMPTY_BOTS }: { extraBots?: ReadonlySet }) { const { myTeam } = useSettings(); @@ -141,18 +141,3 @@ export function TeamPicker({ extraBots = EMPTY_BOTS }: { extraBots?: ReadonlySet
); } - -/** The Settings-drawer wrapper: same Group chrome RepoManagerGroup uses. */ -export function TeamPickerGroup({ extraBots }: { extraBots?: ReadonlySet }) { - return ( -
-

- Your team -

-
- Teammates power the Team view and the “Your team” filter. -
- -
- ); -} From aa23d71ca5b5ea315ed496d23bc1c928229a7204 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 12:15:18 -0700 Subject: [PATCH 259/381] Split stacks wear the connector's stub Stacked PRs still nest with the full indent-and-elbow when parent and child land in the same list, but the board's viewer-relative lanes usually split a stack (the child needs your review while the parent waits on QA), and a child rendering flat carried only the 'stacked on #N' flag: words, no geometry. An owner check read this as the indent having been lost. Give the flat case the same mark in its other standing: the elbow truncated to an 8px stub rising from the row's top edge, where the parent would have been. One vocabulary, two states: full elbow means 'child of the row above', stub means 'child of something that isn't here'. The flag stays as the door that names and links the parent. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/components/Card.tsx | 39 ++++++++++++++++++++++++++++- frontend-v2/src/components/Row.tsx | 1 + 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index 60b65069..af872125 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -43,6 +43,34 @@ function StackConnector({ compact, depth }: { compact: boolean; depth: number }) ); } +/** + * The connector's other standing: a stacked row whose parent ISN'T the row + * above (it lives in another lane, a fold, or another lens' scope) can't + * nest, so it wears the same elbow truncated to a stub — a few quiet pixels + * of the same line, rising to the row's top edge where the parent would + * have been. One vocabulary, two states: the full elbow means "child of + * the row above", the stub means "child of something that isn't here". The + * row's "stacked on #N" flag stays the door that names and links the + * parent; this is pure geometry, so the split-stack case reads at a glance + * instead of only in words. + */ +function StackStub({ compact }: { compact: boolean }) { + const basePad = compact ? 12 : 14; + return ( + + ); +} + export function CardShell({ login, onPerson, @@ -59,6 +87,7 @@ export function CardShell({ avatarBadge, edge, depth = 0, + stackStub = false, }: { login: string; onPerson?: (login: string) => void; @@ -93,11 +122,19 @@ export function CardShell({ * supplies it. Capped at 2 by the model; the geometry doesn't need its * own cap on top of that. */ depth?: number; + /** a stacked pull rendering flat (its parent isn't the row above): wears + * the connector's stub form — see StackStub. Ignored when depth > 0. */ + stackStub?: boolean; }) { const titleLink = ( ); - const connector = depth > 0 && ; + const connector = + depth > 0 ? ( + + ) : stackStub ? ( + + ) : null; if (compact) { return ( diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index f119f560..e0641208 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -732,6 +732,7 @@ function RowImpl({ id={rowDomId(d)} compact={opts.compact} depth={depth} + stackStub={pull.dependent && depth === 0} className={`${flashOnce(key, !!fresh) ? 'row-fresh' : ''} transition-[background-color] duration-150 ease-out motion-reduce:transition-none`} avatarBadge={ starredAuthor && ( From e75fa23909a9d8c88e67925c34925a2ac14a88d9 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 12:20:07 -0700 Subject: [PATCH 260/381] CI is one circle: red disc, progress ring, hover green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three owner calls on the CI mark, one redesign: - The 18px alarm disc with its red label and count was shouting. It earned that size when the rail was crowded with the weight ruler; in the quiet single-line rail, red-as-the-only-filled-disc is already the loudest thing a row can wear. Failing is now the same 14px pip as every other mark: a red ✗ disc, no count, no red label. Red stays binary on purpose: a 1-of-25 failure must read as loudly as 25-of-25, so the fraction lives in the popover, never the mark. - Running finally has a real shape: a slate progress ring sweeping clockwise, the sweep being the completed share of checks. Three checks or twenty-five read as the same quiet dial filling up, which is v1's grey running section reborn in the pip family's geometry. - The quiet-CI reservation was a hole in Classic's narrow columns: wide lanes keep the fixed slot (cross-row alignment is the point there), but wrapped rails under 520px containers collapse it, since a wrapped rail has no columns to hold. The slot also narrows 52px to 36px everywhere now that the count is gone. Legend and DESIGN.md updated; the taxonomy records why the alarm tier retired. Co-Authored-By: Claude Opus 4.8 --- DESIGN.md | 2 +- frontend-v2/src/components/Legend.tsx | 6 ++-- frontend-v2/src/components/bits.tsx | 47 +++++++++++++-------------- frontend-v2/src/styles.css | 28 ++++++++++++---- 4 files changed, 49 insertions(+), 34 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 8a581f22..8b4abdef 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -115,7 +115,7 @@ member of the sign-off family (it did, when weight was a dot): | Shape | Metric | Behavior | |---|---|---| -| circle marks (14px SVG masks, `.pip`) | CI/CR/QA sign-off | ONE family for all three reviewers — the machine is a reviewer too. The label names the reviewer, the glyph names the verdict (✓ approved, ✗ failed, ring = needed/running), the treatment names the standing (solid stands, outlined lapsed). CI is invisible at rest unless failing — its quiet states render in a reserved slot at opacity 0, revealed on row hover, so the reveal can never reflow the line. **The alarm exception**: a failing CI's X disc is the ONE mark drawn larger than the pips (18px, `.pip-alarm`), its label and count in red — broken is the board's rarest and most consequential state, so it escalates by form, never by fill (a filled red chip was prototyped and rejected as badge regression) | +| circle marks (14px SVG masks, `.pip`) | CI/CR/QA sign-off | ONE family for all three reviewers — the machine is a reviewer too. The label names the reviewer, the glyph names the verdict (✓ approved, ✗ failed), the treatment names the standing (solid stands, outlined lapsed). CI is ONE circle in three standings: a red ✗ disc = failing (always binary — a 1-of-25 failure must read as loudly as 25-of-25, so the fraction lives in the popover, never the mark); a slate progress ring sweeping clockwise = running, the sweep being the completed share of checks (continuous, so 3 checks and 25 read the same; the successor to v1's grey running section); a green ✓ disc = passed, revealed on row hover only. All 14px. **The 18px alarm tier is retired**: it earned its size when the rail was crowded with the weight ruler; in the quiet single-line rail, red-as-the-only-filled-disc is already the loudest mark, and the extra size plus red count read as shouting (owner call). In wrapped narrow rails (≤520px containers) the quiet/no-check CI slot collapses instead of reserving a hole — cross-row alignment only exists in wide lanes | | quiet text letter (11px, same fixed slot as the CI/CR/QA label) | review weight | XS/S/M/L/XL, or "?" when the wire sent no size — not its own shape at all, deliberately: it rides inside the CR cluster (`CR` label · weight letter · pips) instead of inventing a fourth family or drawing a ratio strip. A prior 4px fill-doubles-per-class strip under the whole sign-off row was retired (2026-07): the owner found it visually loud and it only ever said what a letter already says. Same popover survives the move — the effort word, the exact +/− diff, and the "Filter to X PRs" action | | the row's baseline (1px hairline, bottom edge) | age / starvation | **gated, not always-on**: nothing below the aging threshold, then a grey hairline — a deepened stretch of the divider the row already has, never a drawn bar. **Relative, not thresholded**: the board's longest-open pull sets the full track and the deepest tint (ultra-light ink → full ink-3); every other row is a fraction of the oldest. **Grey only** — the amber version was disruptive and a red plateau before it read as "broken" everywhere; age is a quiet fact, urgency lives in the queue's ranking and the numeral's font weight. The quiet day count floats right in the meta line, capping the track. **The line is also a door** (2026-07): a taller invisible hit strip (10px, sized to the line's own drawn fraction, not the full row) makes a 1px target hoverable without pixel-hunting; hover or focus grows the line to an 8px band and opens the same age popover the numeral shows (opened X ago, last activity Y ago, the relative-to-the-oldest line) — one popover body, two doors, so they can't drift apart | diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 8ed7a7f7..3c88a8eb 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -164,11 +164,11 @@ export function Legend() { term={ CI - - + + } - def="CI wears the same marks as CR and QA. The machine is a reviewer too. A red ✗ disc, drawn a size larger than every other mark, means failing checks (the count beside it says how many); the gray-blue ring means still running. Passing shows nothing at rest. No news is good news; hover a row for its quiet green check and the per-check list" + def="CI wears the same marks as CR and QA. The machine is a reviewer too. A red ✗ disc means one or more checks failed (the popover says which); a gray-blue ring filling clockwise means the checks are still running, and the filled share is how many have finished. Passing shows nothing at rest. No news is good news; hover a row for its quiet green check and the per-check list" /> } diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 4f21edd8..285491db 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import type { ButtonHTMLAttributes, KeyboardEvent as ReactKeyboardEvent } from 'react'; +import type { ButtonHTMLAttributes, CSSProperties, KeyboardEvent as ReactKeyboardEvent } from 'react'; import { Check, CircleDot, Star, X } from 'lucide-react'; import { type DerivedPull, @@ -319,7 +319,7 @@ export function CiStatus({ pull }: { pull: DerivedPull }) { : [...headStatuses(pull.data)].sort( (a, b) => ciRank(a) - ciRank(b) || a.data.context.localeCompare(b.data.context) ); - if (!checks.length) return ; + if (!checks.length) return ; const failing = checks.filter(isRedCheck).length; const passing = checks.filter(c => c.data.state === 'success').length; @@ -350,34 +350,33 @@ export function CiStatus({ pull }: { pull: DerivedPull }) { // One fixed slot width in every state (sized to the failing // cluster, the widest), so the rail — and the age numeral's // right edge in the meta line — stays one column down a lane. - className={`pressable -my-2 mr-1.5 inline-flex w-[52px] items-center gap-1 rounded border-0 bg-transparent px-0 py-2 hover:bg-secondary/60 ${ - failing > 0 + className={`pd-ci-slot pressable -my-2 mr-1.5 inline-flex w-[36px] items-center gap-1 rounded border-0 bg-transparent px-0 py-2 hover:bg-secondary/60 ${ + failing > 0 || pending ? '' - : 'opacity-0 transition-opacity duration-150 hover:opacity-100 focus-visible:opacity-100 [.pd-row:hover_&]:opacity-100 motion-reduce:transition-none' + : 'pd-ci-quiet opacity-0 transition-opacity duration-150 hover:opacity-100 focus-visible:opacity-100 [.pd-row:hover_&]:opacity-100 motion-reduce:transition-none' }`} > - 0 ? 'font-semibold' : 'font-medium text-ink-3' - }`} - style={failing > 0 ? { color: 'var(--bad)' } : undefined} - > + CI - 0 ? 'pip-fail pip-alarm' : pending ? 'pip-run' : 'pip-on' - }`} - /> - {failing > 0 && ( + {/* one circle, three standings: a red ✗ disc = failing (the + fraction lives in the popover; a 1-of-25 failure must read + as loudly as 25-of-25), a slate ring sweeping closed = + running (the sweep is the completed share, v1's grey + section reborn), a green ✓ disc = passed, revealed on row + hover only. Same 14px as every other pip: with the weight + ruler gone the rail is quiet enough that red needs no size + escalation to be its loudest mark. */} + {failing > 0 ? ( + + ) : pending ? ( - {failing} - + aria-hidden + className="pip-progress" + style={{ '--sweep': `${Math.round(((checks.length - pendingCount) / checks.length) * 360)}deg` } as CSSProperties} + /> + ) : ( + )} )} diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 0558ba1f..8a1efc67 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -224,17 +224,26 @@ label:has(> input:not(:disabled)) { --pip-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cdefs%3E%3Cmask id='x'%3E%3Crect width='16' height='16' fill='white'/%3E%3Cpath d='M5.4 5.4l5.2 5.2M10.6 5.4l-5.2 5.2' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'/%3E%3C/mask%3E%3C/defs%3E%3Ccircle cx='8' cy='8' r='7.4' fill='white' mask='url(%23x)'/%3E%3C/svg%3E"); background-color: var(--bad); } - /* the alarm tier: broken is the one state allowed to outsize the pips — - the rarest signal owns the loudest honest channel (form, not fill) */ - .pip-alarm { - width: 18px; - height: 18px; - } .pip-run { --pip-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.6' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E"); background-color: var(--slate); } + /* CI while running: the run ring sweeping shut. The ring mask above cuts + the circle; a conic gradient paints the completed share of checks in + slate and leaves the rest as the secondary track, so 3 checks and 25 + checks both read as one quiet dial filling clockwise (v1's grey + "running" section, in the pip family's geometry). --sweep is set inline + by CiStatus. */ + .pip-progress { + width: 14px; + height: 14px; + flex: none; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.6' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E") center / 100% 100% no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.6' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E") center / 100% 100% no-repeat; + background: conic-gradient(var(--slate) var(--sweep, 0deg), var(--secondary) var(--sweep, 0deg)); + } + /* the slot you personally stamped: a dotted rule under the marks */ .pip-mine { border-bottom: 1px dotted var(--ink-3); padding-bottom: 2px; } @@ -321,6 +330,13 @@ label:has(> input:not(:disabled)) { justify-end. flex-basis:100% can't share a line with anything, so it wraps whole. */ @container (max-width: 520px) { + /* a wrapped rail has no cross-row columns to hold: the reserved quiet-CI + slot (and the no-checks placeholder) would just be a hole, so it + collapses here. Wide lanes keep the reservation for alignment. */ + .pd-ci-quiet, + span.pd-ci-slot { + display: none; + } .pd-rail { flex-basis: 100%; margin-left: 0; From b38b61271d75a65905c8274313bcc77a77527252 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 12:21:18 -0700 Subject: [PATCH 261/381] Age line ramps to the border color, not ink The relative age hairline mixed ink-3 from 30% to 100%, which made an old pull's line darker than the row dividers around it; the owner read it as too dark. The ramp now mixes the border token instead, so the board's oldest pull draws exactly a divider-weight line and everything younger is a lighter fraction of it. The line stays a deepened stretch of the border the row already has, never a darker stroke than its surroundings. DESIGN.md's taxonomy row updated to match. Co-Authored-By: Claude Opus 4.8 --- DESIGN.md | 2 +- frontend-v2/src/components/bits.tsx | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 8b4abdef..8b75fdbc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -117,7 +117,7 @@ member of the sign-off family (it did, when weight was a dot): |---|---|---| | circle marks (14px SVG masks, `.pip`) | CI/CR/QA sign-off | ONE family for all three reviewers — the machine is a reviewer too. The label names the reviewer, the glyph names the verdict (✓ approved, ✗ failed), the treatment names the standing (solid stands, outlined lapsed). CI is ONE circle in three standings: a red ✗ disc = failing (always binary — a 1-of-25 failure must read as loudly as 25-of-25, so the fraction lives in the popover, never the mark); a slate progress ring sweeping clockwise = running, the sweep being the completed share of checks (continuous, so 3 checks and 25 read the same; the successor to v1's grey running section); a green ✓ disc = passed, revealed on row hover only. All 14px. **The 18px alarm tier is retired**: it earned its size when the rail was crowded with the weight ruler; in the quiet single-line rail, red-as-the-only-filled-disc is already the loudest mark, and the extra size plus red count read as shouting (owner call). In wrapped narrow rails (≤520px containers) the quiet/no-check CI slot collapses instead of reserving a hole — cross-row alignment only exists in wide lanes | | quiet text letter (11px, same fixed slot as the CI/CR/QA label) | review weight | XS/S/M/L/XL, or "?" when the wire sent no size — not its own shape at all, deliberately: it rides inside the CR cluster (`CR` label · weight letter · pips) instead of inventing a fourth family or drawing a ratio strip. A prior 4px fill-doubles-per-class strip under the whole sign-off row was retired (2026-07): the owner found it visually loud and it only ever said what a letter already says. Same popover survives the move — the effort word, the exact +/− diff, and the "Filter to X PRs" action | -| the row's baseline (1px hairline, bottom edge) | age / starvation | **gated, not always-on**: nothing below the aging threshold, then a grey hairline — a deepened stretch of the divider the row already has, never a drawn bar. **Relative, not thresholded**: the board's longest-open pull sets the full track and the deepest tint (ultra-light ink → full ink-3); every other row is a fraction of the oldest. **Grey only** — the amber version was disruptive and a red plateau before it read as "broken" everywhere; age is a quiet fact, urgency lives in the queue's ranking and the numeral's font weight. The quiet day count floats right in the meta line, capping the track. **The line is also a door** (2026-07): a taller invisible hit strip (10px, sized to the line's own drawn fraction, not the full row) makes a 1px target hoverable without pixel-hunting; hover or focus grows the line to an 8px band and opens the same age popover the numeral shows (opened X ago, last activity Y ago, the relative-to-the-oldest line) — one popover body, two doors, so they can't drift apart | +| the row's baseline (1px hairline, bottom edge) | age / starvation | **gated, not always-on**: nothing below the aging threshold, then a grey hairline — a deepened stretch of the divider the row already has, never a drawn bar. **Relative, not thresholded**: the board's longest-open pull sets the full track and the deepest tint (ultra-light grey → the full border color, deliberately no darker than the row dividers it extends); every other row is a fraction of the oldest. **Grey only** — the amber version was disruptive and a red plateau before it read as "broken" everywhere; age is a quiet fact, urgency lives in the queue's ranking and the numeral's font weight. The quiet day count floats right in the meta line, capping the track. **The line is also a door** (2026-07): a taller invisible hit strip (10px, sized to the line's own drawn fraction, not the full row) makes a 1px target hoverable without pixel-hunting; hover or focus grows the line to an 8px band and opens the same age popover the numeral shows (opened X ago, last activity Y ago, the relative-to-the-oldest line) — one popover body, two doors, so they can't drift apart | The rail is one instrument, one line: `CI` then the `CR` cluster (label, weight letter, sign-off pips) then the `QA` cluster (label, pips). Age diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 285491db..5885e889 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -722,8 +722,9 @@ export function AgeBaseline({ }) { if (quiet || ageDays < warnDays) return null; const t = Math.min(ageDays / Math.max(maxAgeDays, 1), 1); - // tint rides the same fraction as length: ~30% ink at the gate, full - // ink-3 on the board's oldest + // tint rides the same fraction as length: ~30% border-grey at the + // gate, the full border color on the board's oldest (owner call: the + // ink ramp read too dark against the row divider it extends) const inkPct = Math.round(30 + 70 * t); return ( // the percentage width lives on this outer track (not the line itself, @@ -752,7 +753,7 @@ export function AgeBaseline({ aria-hidden className="pd-age-line" style={{ - background: `color-mix(in oklab, var(--ink-3) ${inkPct}%, transparent)`, + background: `color-mix(in oklab, var(--border) ${inkPct}%, transparent)`, }} /> From a438f6a24800bab16226d7cd7c6ea845e34f10b7 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 12:27:58 -0700 Subject: [PATCH 262/381] Climbing requires your own stamp; add the overtaken nudge An audit of every cheer transition rule chased the report that 'You're climbing' fires when you move DOWN the leaderboard. The comparator is provably correct (rank 1 is best; the cheer gates on the number shrinking) and the literal report doesn't reproduce; a regression test now pins it. But the audit surfaced the real cause of the felt bug: rank is computed over the live board, so when other people's reviewed PRs merge away, their stamps leave the count and YOUR rank improves while you did nothing. The cheer then congratulates you at random. Climbing now also requires your own stamp count to have grown since the last tick: a climb you caused, never one you inherited. The baseline carries myCount (with revive defaults for persisted blobs, and eviction reverting it alongside rank so a capped-out cheer still retries). New nudge, owner-requested: when your rank gets worse and the board can name who now holds your old rank, 'alice took your #4 spot' / 'You're #5 on the board now.' Quiet nag tone, once per overtake, refires only if you reclaim the spot and lose it again. Registered in the catalog and priority order, so the per-kind toggle and the 1:1-with-priority test cover it. The rest of the audit: every other transition rule verified correct, including the loading guard that prevents phantom inbox-zero on refresh and the null-at claim-nag guard from the claims migration. One deliberate non-change: return-the-favor's session-permanent dedupe reads as designed-not-broken and is left as is. 352 tests (+8). Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/model/cheers.test.ts | 97 ++++++++++++++++++++++++++++ frontend-v2/src/model/cheers.ts | 64 ++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/frontend-v2/src/model/cheers.test.ts b/frontend-v2/src/model/cheers.test.ts index 5f7edf94..5095eed8 100644 --- a/frontend-v2/src/model/cheers.test.ts +++ b/frontend-v2/src/model/cheers.test.ts @@ -82,6 +82,7 @@ function sig(o: Partial & { queue?: number; pulls?: DerivedPull[] } = { staleClaims: o.staleClaims ?? new Map(), staleClaimAfter: o.staleClaimAfter ?? 'a couple hours', requestedOfMe: o.requestedOfMe ?? new Map(), + rankHolders: o.rankHolders ?? new Map(), }; } @@ -390,6 +391,14 @@ describe('diffCheers — leaderboard', () => { expect(toasts.some(t => t.icon === '📈' && t.title === "You're climbing")).toBe(true); }); + it('does not fire climbing when your rank improves passively (no new stamp of yours)', () => { + // other people's reviewed PRs merged away, so your rank number got + // better while your own count never moved; that is not your climb + const base = primed(sig({ myRank: 3, myCount: 2 })); + const { toasts } = diffCheers(sig({ myRank: 2, myCount: 2, peerBelow: 'bob' }), 'me', base); + expect(toasts.some(t => t.icon === '📈')).toBe(false); + }); + it('does not fire climbing without an identifiable peer', () => { const base = primed(sig({ myRank: 3, myCount: 2 })); const { toasts } = diffCheers(sig({ myRank: 2, myCount: 3, peerBelow: null }), 'me', base); @@ -401,6 +410,94 @@ describe('diffCheers — leaderboard', () => { const { toasts } = diffCheers(sig({ myRank: 1, myCount: 3, peerBelow: 'bob' }), 'me', base); expect(toasts.some(t => t.icon === '📈')).toBe(false); }); + + // Regression test for the reported bug: "climbing" was firing when the + // viewer's rank got numerically WORSE (moved down the board), not better. + // Rank 1 is best, so climbing must require the numeric rank to decrease. + it('does not fire climbing when your rank gets worse (was #3, now #4)', () => { + const base = primed(sig({ myRank: 3, myCount: 5 })); + const { toasts } = diffCheers(sig({ myRank: 4, myCount: 5, peerBelow: 'bob' }), 'me', base); + expect(toasts.some(t => t.icon === '📈' || t.title === "You're climbing")).toBe(false); + }); +}); + +describe('diffCheers — overtaken', () => { + it('fires when your rank slips and names whoever now holds your old spot', () => { + const base = primed(sig({ myRank: 3, myCount: 4 })); + const rankHolders = new Map([[3, ['alice']]]); + const { toasts } = diffCheers(sig({ myRank: 4, myCount: 4, rankHolders }), 'me', base); + const toast = toasts.find(t => t.dedupeKey?.startsWith('overtaken:')); + expect(toast).toBeDefined(); + expect(toast?.tone).toBe('nag'); + expect(toast?.title).toBe('alice took your #3 spot'); + expect(toast?.body).toBe("You're #4 on the board now."); + expect(toast?.dedupeKey).toBe('overtaken:3:alice'); + }); + + it('picks the alphabetically-first holder when a tie shares your old rank', () => { + const base = primed(sig({ myRank: 2, myCount: 6 })); + const rankHolders = new Map([[2, ['zeb', 'alice']]]); + const { toasts } = diffCheers(sig({ myRank: 3, myCount: 6, rankHolders }), 'me', base); + const toast = toasts.find(t => t.dedupeKey?.startsWith('overtaken:')); + expect(toast?.title).toBe('alice took your #2 spot'); + }); + + it('does not fire when nobody is identifiably at your old rank', () => { + const base = primed(sig({ myRank: 3, myCount: 4 })); + const { toasts } = diffCheers( + sig({ myRank: 4, myCount: 4, rankHolders: new Map() }), + 'me', + base + ); + expect(toasts.some(t => t.dedupeKey?.startsWith('overtaken:'))).toBe(false); + }); + + it('does not fire when your rank holds steady or improves', () => { + const base = primed(sig({ myRank: 3, myCount: 4 })); + const rankHolders = new Map([[3, ['alice']]]); + const steady = diffCheers(sig({ myRank: 3, myCount: 4, rankHolders }), 'me', base); + expect(steady.toasts.some(t => t.dedupeKey?.startsWith('overtaken:'))).toBe(false); + const improved = diffCheers( + sig({ myRank: 2, myCount: 5, rankHolders, peerBelow: 'bob' }), + 'me', + base + ); + expect(improved.toasts.some(t => t.dedupeKey?.startsWith('overtaken:'))).toBe(false); + }); + + it('fires once per drop, then stays silent while the standing persists', () => { + const base = primed(sig({ myRank: 3, myCount: 4 })); + const rankHolders = new Map([[3, ['alice']]]); + const first = diffCheers(sig({ myRank: 4, myCount: 4, rankHolders }), 'me', base); + expect(first.toasts.some(t => t.dedupeKey === 'overtaken:3:alice')).toBe(true); + const second = diffCheers(sig({ myRank: 4, myCount: 4, rankHolders }), 'me', first.next); + expect(second.toasts.some(t => t.dedupeKey?.startsWith('overtaken:'))).toBe(false); + }); + + it('can fire again after climbing back then dropping a second time', () => { + const base = primed(sig({ myRank: 3, myCount: 4 })); + const rankHoldersAt3 = new Map([[3, ['alice']]]); + const dropped = diffCheers( + sig({ myRank: 4, myCount: 4, rankHolders: rankHoldersAt3 }), + 'me', + base + ); + expect(dropped.toasts.some(t => t.dedupeKey === 'overtaken:3:alice')).toBe(true); + // reclaim #3 + const reclaimed = diffCheers( + sig({ myRank: 3, myCount: 6, peerBelow: 'carol' }), + 'me', + dropped.next + ); + expect(reclaimed.toasts.some(t => t.dedupeKey?.startsWith('overtaken:'))).toBe(false); + // drop again, same rank transition + const droppedAgain = diffCheers( + sig({ myRank: 4, myCount: 6, rankHolders: rankHoldersAt3 }), + 'me', + reclaimed.next + ); + expect(droppedAgain.toasts.some(t => t.dedupeKey === 'overtaken:3:alice')).toBe(true); + }); }); describe('diffCheers — author-side toasts', () => { diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index 47ef059c..d4c111cc 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -68,6 +68,10 @@ export interface CheerBaseline { favorsSeen: ReadonlySet; /** your leaderboard rank last tick (0 = unranked / no stamps in view) */ myRank: number; + /** your stamp count last tick — climbing requires it to have GROWN, so a + * rank that improves passively (someone else's reviewed PRs merged away) + * never cheers you for work you didn't do */ + myCount: number; /** you were the #1 reviewer on the board last tick */ wasTop: boolean; /** review requests already toasted, so each fires once per pull; rebuilt @@ -97,6 +101,7 @@ export const EMPTY_BASELINE: CheerBaseline = { quickWinsNagged: false, favorsSeen: new Set(), myRank: 0, + myCount: 0, wasTop: false, requestedSeen: new Set(), hadBacklog: false, @@ -126,6 +131,7 @@ export function serializeBaseline(b: CheerBaseline): unknown { quickWinsNagged: b.quickWinsNagged, favorsSeen: [...b.favorsSeen], myRank: b.myRank, + myCount: b.myCount, wasTop: b.wasTop, requestedSeen: [...b.requestedSeen], hadBacklog: b.hadBacklog, @@ -157,6 +163,7 @@ export function reviveBaseline(raw: unknown): CheerBaseline | null { quickWinsNagged: !!o.quickWinsNagged, favorsSeen: new Set(arr(o.favorsSeen) as string[]), myRank: Number(o.myRank) || 0, + myCount: Number(o.myCount) || 0, wasTop: !!o.wasTop, requestedSeen: new Set(arr(o.requestedSeen) as string[]), hadBacklog: !!o.hadBacklog, @@ -349,6 +356,10 @@ export interface Signals { /** pulls GitHub has asked YOU to review (and you haven't yet), for the * review-requested toast — the most direct "review this" the board carries */ requestedOfMe: Map; + /** logins holding each rank right now, you excluded, keyed by rank number — + * the leaderboard neighborhood the overtaken nudge reads to name whoever + * now sits at the rank you held last tick */ + rankHolders: Map; } export function readSignals(input: CheerInput): Signals { @@ -483,6 +494,16 @@ export function readSignals(input: CheerInput): Signals { .sort(); peerBelow = candidates[0] ?? null; } + // who holds each rank right now, so a later tick can name whoever now sits + // at the rank the viewer held last time (the overtaken nudge); same + // dense-rank tie caveat as peerBelow, since two logins can share a rank + const rankHolders = new Map(); + for (const [login, r] of ranks) { + if (login === me) continue; + const holders = rankHolders.get(r.rank) ?? []; + holders.push(login); + rankHolders.set(r.rank, holders); + } return { stamped, @@ -506,6 +527,7 @@ export function readSignals(input: CheerInput): Signals { staleClaims, staleClaimAfter, requestedOfMe, + rankHolders, }; } @@ -534,6 +556,7 @@ export const PRIORITY_ORDER = [ 'milestone', 'top-of-board', 'climbing', + 'overtaken', 'stamp-landed', 'pr-first-review', 'review-requested', @@ -662,6 +685,12 @@ export const CHEER_CATALOG: { label: 'Claimed but still unreviewed', hint: 'A review you claimed has sat unreviewed too long.', }, + { + kind: 'overtaken', + group: 'nudge', + label: 'Overtaken on the board', + hint: 'Someone passes you on the leaderboard.', + }, // Your PRs — author-side alerts { kind: 'pr-first-review', @@ -758,6 +787,7 @@ export function diffCheers( quickWinsNagged: sig.quickWinCount >= QUICK_WIN_THRESHOLD, favorsSeen: new Set(), myRank: sig.myRank, + myCount: sig.myCount, wasTop: sig.myRank === 1, requestedSeen: new Set(sig.requestedOfMe.keys()), hadBacklog: sig.backlog > 0, @@ -993,6 +1023,7 @@ export function diffCheers( // state in the baseline so they re-fire while the standing persists. let nextWasTop = sig.myRank === 1; let nextMyRank = sig.myRank; + let nextMyCount = sig.myCount; if (sig.myRank === 1 && !base.wasTop && sig.myCount > 0) { push( 'top-of-board', @@ -1015,6 +1046,11 @@ export function diffCheers( base.myRank > 0 && sig.myRank > 0 && sig.myRank < base.myRank && + // only cheer a climb you caused: rank also improves passively when + // other people's reviewed PRs merge and their stamps leave the board, + // and congratulating you for someone else's merge reads as the cheer + // firing at random (the owner experienced exactly this) + sig.myCount > (base.myCount ?? 0) && sig.myRank > 1 && sig.myCount >= 2 && sig.peerBelow @@ -1035,9 +1071,36 @@ export function diffCheers( }, () => { nextMyRank = base.myRank; + nextMyCount = base.myCount; } ); } + // the inverse of climbing: your rank got numerically worse (you dropped), + // and someone identifiable now sits at the rank you held last tick. Same + // edge-triggered shape as climbing (nextMyRank only advances once the + // toast survives eviction, so the same drop keeps offering the nudge until + // it's shown), just naming the person instead of staying anonymous, since + // rankHolders reads the CURRENT tick's full board rather than guessing + // from a single peer. + if (base.myRank > 0 && sig.myRank > 0 && sig.myRank > base.myRank) { + const holders = [...(sig.rankHolders.get(base.myRank) ?? [])].sort(); + const overtaker = holders[0] ?? null; + if (overtaker) { + push( + 'overtaken', + { + tone: 'nag', + icon: '📉', + title: `${overtaker} took your #${base.myRank} spot`, + body: `You're #${sig.myRank} on the board now.`, + dedupeKey: `overtaken:${base.myRank}:${overtaker}`, + }, + () => { + nextMyRank = base.myRank; + } + ); + } + } // author-side: your own open PRs, watched for edge transitions only — a // brand-new key (a PR you just opened) has no baseline entry yet, so it @@ -1191,6 +1254,7 @@ export function diffCheers( quickWinsNagged, favorsSeen, myRank: nextMyRank, + myCount: nextMyCount, wasTop: nextWasTop, requestedSeen, hadBacklog: nextHadBacklog, From 5d77241420dc916b9e521d5bba36e5ae8a71eb76 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 12:36:55 -0700 Subject: [PATCH 263/381] Dummy board carries a full 3-deep nesting demo The synthetic stacks existed but never co-rendered nested: a dependent pull's status differs from its parent's, and per-member fixture noise (a stale CR on the grandchild, the root being a plain wait) scattered the chain across the move/wait lanes, so the dummy board only ever demonstrated the flat-with-stub case. The design bench needs both standings of the connector visible. The chain (#35168 -> #35177 -> #35194) is now authored by the dummy viewer with uniform state: every member conflicted and signature-free, so all three share one bucket and render three deep with the indent and elbow on My work and on Review's Waiting on you. The fork stack stays authored by others, keeping the split-stack stub demo alive on the same board. Co-Authored-By: Claude Opus 4.8 --- frontend-v2/src/backend/dummy.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts index 0bfadbf0..047e2e5e 100644 --- a/frontend-v2/src/backend/dummy.ts +++ b/frontend-v2/src/backend/dummy.ts @@ -154,6 +154,31 @@ function withSyntheticStacks(pulls: PullData[]): PullData[] { const [forkParent, forkChild1, forkChild2] = FORK_INDEXES; rebase(forkChild1, out[forkParent].head.ref); rebase(forkChild2, out[forkParent].head.ref); + // The chain belongs to the dummy viewer: a dependent pull's status differs + // from its parent's, so on Review the members scatter across lanes and + // only ever demo the flat-with-stub case. My work is the one surface that + // holds a whole chain in one list (your own PRs, and WordGroupRows pulls + // children into their root's bucket), so a viewer-authored chain is the + // standing FULL demo of nesting: indent + elbow, three deep. The fork + // stays authored by others, which keeps the split-stack stub demo alive + // on Review at the same time. + for (const i of CHAIN_INDEXES) { + out[i] = { ...out[i], user: { ...out[i].user, login: dummyUser() } }; + } + // ...and every chain member gets a merge conflict and a clean signature + // slate. The children are already 'unmergeable' by virtue of being + // dependent, but per-member noise (the grandchild's fixture carries a + // stale CR, the root would otherwise be a plain wait) scatters the three + // across the move/wait lanes, and a chain only nests when its members + // share one list. Uniform state = one bucket = the standing 3-deep demo + // of indent + elbow on My work and Review both. + for (const i of CHAIN_INDEXES) { + out[i] = { + ...out[i], + mergeable: false, + status: { ...out[i].status, allCR: [], allQA: [] }, + }; + } return out; } From e5086763c53495a90312a302d2e5ff84e122eb5b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 12:39:05 -0700 Subject: [PATCH 264/381] Record the session's doctrine: salience, alarms, settings, demos Four lessons from this design round earn a place in the constitution so they can't be quietly unlearned: - Salience is relative to its era: a mark's loudness is set by its neighbors (the 18px CI alarm was correct beside the 112px ruler and shouting once the ruler died). Re-audit loudness after quieting a region. - A boolean alarm is never a proportion, and one mark can carry a relation's attached/detached standings (the connector's stub). - Settings & configuration doctrine: controls live where they act, identity data is not a setting, the panel must be seeable whole, derived values beat sibling knobs, and sticky headers over interactive scroll content need an explicit z-index. - Feedback & celebrations: only cheer what the user caused; nags are once per subject with an explicit refire rule. - The dummy board is the design bench: every mark's every standing must be permanently visible, synthesized deliberately when the fixture can't produce it naturally. Co-Authored-By: Claude Opus 4.8 --- DESIGN.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index 8b75fdbc..8f7d7a4a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -130,6 +130,25 @@ Marks are drawn as SVG masks / CSS geometry, never font glyphs — a text ✓ at 10px is at the mercy of the platform rasterizer (a struck-through ✓ was conceptually perfect and rendered as a blob). +**Salience is relative to its era.** A mark's loudness is set by its +neighbors, not by its own pixels: the 18px CI alarm was right next to a +112px ruler and shouting once the ruler died. After removing or quieting +anything in a region, re-audit what now reads loudest there — escalations +earned in a crowded layout rarely survive a quiet one. + +**A boolean alarm is never a proportion.** Encoding "broken" as a share +(a red arc sized failing/total) makes one failure out of 25 a 4% sliver — +invisible for the state that most needs seeing. Alarms are binary at full +strength; continuous encodings (the CI progress ring's sweep, the age +line's length) are reserved for facts that are genuinely continuous. The +counts live in the popover. + +**One mark can also carry a relation's two standings.** The stack +connector's full elbow means "child of the row above"; the same line +truncated to an 8px stub means "child of something that isn't here." When +a relationship's other end may be off-screen, truncate the mark rather +than inventing a second vocabulary — the full form teaches the stub. + ### Icons One family: every icon on the board renders through `lucide-react` (ISC, @@ -171,6 +190,39 @@ icon with a class bolted on. band labels share the exported constant, never a hand-rolled copy) > `Fold` summaries (disclosure rows, not titles). +## Settings & configuration + +- **A control lives where its effect is visible.** Notification prefs + behind the bell, filter defaults inside their filters ("Make this my + default" when the session differs), team and repos edited on the board + surfaces that show them. The Settings panel is the home of last resort, + not the junk drawer. +- **Identity data is not a setting.** Your team, repos, and regions are + workspace data with their own editors; embedding full pickers in a + preferences panel cost 31% of its scroll and made it a "large list" + (owner's words, then measured: 2,846px, 20% visible at once). +- **The panel must be seeable whole.** The test for done: a first-time + user can enumerate every setting without scrolling much; verbs (refresh, + reset) and rarely-touched timers fold into one Advanced disclosure, + destructive action last. +- **Two knobs on one concept is one too many.** Derived values (rot = + 2.5 × warn) beat sibling fields nobody tunes independently. +- **Sticky headers over scrolling interactive content need an explicit + z-index and an opaque background** — every `.hit` control is positioned + and will paint over an unranked sticky header in DOM order (this + shipped as a real mobile bug). + +## Feedback & celebrations + +- **Only cheer what the user caused.** Board-relative facts improve + passively (your rank climbs when someone else's reviewed PRs merge + away); a celebration gated only on the fact's transition fires "at + random" from the user's seat. Require the user's own action in the + gate (climbing demands your own stamp count grew). +- **Nags are once per subject** with an explicit refire rule (overtaken + refires only after you reclaim and lose the spot again); dedupe keys + name the subject, not the tick. + ## Copy rules (static text is part of the visual system) - **Say the thing, don't be clever.** Lane names state their contents @@ -206,6 +258,12 @@ icon with a class bolted on. - Judge at **real render px** and zoomed, in-situ beside real neighbors, in **both themes** — on the dummy board (`npm run dev:dummy`). +- **The dummy board is the design bench: every mark's every standing must + be permanently visible on it.** Viewer-relative lanes can structurally + hide a state (a stack only nests when its members share one list, so no + chain ever nested until the fixture was synthesized with uniform state). + When a state can't occur naturally in the fixture, synthesize it + deterministically and comment why. - Prototype candidates with throwaway DOM/JS mutation in the live page; let the owner pick from *rendered* candidates, not descriptions. - For any salience claim, do the arithmetic (area × contrast) — one From 684827aea1850034c31a7ea06f7f2c7d959972ec Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 12:56:04 -0700 Subject: [PATCH 265/381] Quiet the filter bar: the trigger is the chip, hiding gets a ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar's normal state is a few filters always on, and it charged full price for that: an active dimension lit its trigger with a brand border, brand fill, and brand text, then FilterChips restated the same fact as a second bordered pill beside it — five marks (two of them brand-filled) for two facts, on top of a resting row of six bordered, icon-fronted buttons. Your own standing configuration was the loudest thing on the page. Collapse the whole vocabulary to one typographic ladder: - Triggers are quiet text (FilterTrigger in shared.tsx): muted word + chevron at rest; active = the dimension's VALUE named in ink and medium weight ("Weight · XS, S") with a small × that clears just that dimension. No borders, no fills, no icons — six pictograms said less than the six words. FilterChips is deleted; the trigger is the chip. One "Reset" replaces "Clear filters". A single selected repo/person names itself; more collapse to a count. - Hiding becomes a ledger (HiddenPanel): a "N hidden" door at the end of the trigger family that always says how many open PRs the board is withholding, and opens into the why — parked PRs (the Cryogenic Storage gloss moves into the row), drafts by others, muted repos, muted people, snoozed — each with its count. Parked and drafts toggle right there (with make-my-default); the durable mutes say where their controls live instead of duplicating them. "Show parked PRs" leaves the bottom of the Repos popover, where it was a repo setting in name only; the Drafts trigger dissolves into the drafts row. While any session reveal is on, the door reads "showing hidden ×". - The visibility rules unify into one boardHidden predicate in app.tsx, shared by the pipeline and the ledger's counts so the door's number can never disagree with what the board actually hides. Deliberate behavior change riding along: "Show everything for now" (showAll) now also reveals other people's drafts — the master switch previously showed everything except drafts, which made its own label a lie. - Mute counts leave the Repos/People trigger labels: a trigger lights only when it narrows the board; durable state reads in the ledger. On mobile the words survive: the old bar collapsed to icon-only squares below sm, so an active filter was invisible on a phone. Quiet text wraps to two readable lines instead. Verified live on the dummy board: per-dimension ×, ledger reveal/undo, master reveal, Reset, muted-repo row appearing on mute, mobile wrap, dark theme. 352 client tests, tsc, eslint, build all green. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 183 +++++++++------ frontend-v2/src/components/Legend.tsx | 4 +- .../src/components/SavedFiltersPanel.tsx | 23 +- .../src/components/filters/DraftsFilter.tsx | 77 ------- .../src/components/filters/FilterChips.tsx | 218 ------------------ .../src/components/filters/HiddenPanel.tsx | 209 +++++++++++++++++ .../src/components/filters/PeopleFilter.tsx | 41 ++-- .../src/components/filters/RepoFilter.tsx | 92 ++------ .../src/components/filters/StateFilter.tsx | 38 ++- .../src/components/filters/WeightFilter.tsx | 32 +-- frontend-v2/src/components/filters/shared.tsx | 96 ++++++++ 11 files changed, 492 insertions(+), 521 deletions(-) delete mode 100644 frontend-v2/src/components/filters/DraftsFilter.tsx delete mode 100644 frontend-v2/src/components/filters/FilterChips.tsx create mode 100644 frontend-v2/src/components/filters/HiddenPanel.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index d116b306..a1b4cafb 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -36,8 +36,8 @@ import { RepoFilter } from './components/filters/RepoFilter'; import { PeopleFilter } from './components/filters/PeopleFilter'; import { WeightFilter } from './components/filters/WeightFilter'; import { StateFilter } from './components/filters/StateFilter'; -import { DraftsFilter } from './components/filters/DraftsFilter'; -import { FilterChips, hasActiveFilters } from './components/filters/FilterChips'; +import { HiddenPanel } from './components/filters/HiddenPanel'; +import { hasActiveFilters } from './components/filters/shared'; import { SavedFiltersInput, SavedFiltersMenu } from './components/SavedFiltersPanel'; import type { RowOptions } from './components/Row'; import { Review } from './views/Review'; @@ -464,6 +464,61 @@ export function App() { [scope.authors, queryAuthors] ); + // The one is-this-pull-off-the-board predicate: muted (user) and + // org-hidden repos, muted people, cryo, snoozes, and the drafts rule stay + // off unless a session act reveals them — an explicit reveal, a scope, a + // repo:/author: query term, or the master "show everything". Never hides + // your own pulls or bots via mutes: bots have their own fold, and a person + // can't mute themselves off their own board. Shared by the filter pipeline + // and the hidden-PR ledger's counts so the ledger's number can never + // disagree with what the board actually withholds. + const boardHidden = useCallback( + (p: DerivedPull) => { + if (showAll) return false; + const hiddenRepo = + repoHidden(p.data.repo, hiddenRepos, settings.repoPrefs) && !revealedRepo(p.data.repo); + const hiddenPerson = + p.data.user.login !== me && + !isBot(p) && + personHidden(p.data.user.login, settings.mutedPeople) && + !revealedAuthor(p.data.user.login); + const cryoHidden = p.cryo && !settings.showCryo && !reveal.includes(CRYO_KEY); + // drafts: 'mine' keeps other people's drafts quiet on the general + // board, but not when you've deliberately looked (scoped/`author:`d + // that person) or when GitHub explicitly requested your review — a + // review-requested draft was vanishing even from the reviewer it was + // requested from, the reported bug. Your own drafts always show. The + // legacy path owns its own draft rule, so don't double-apply. + const draftHidden = + !legacy && + draftsMode === 'mine' && + p.data.draft && + p.data.user.login !== me && + !revealedAuthor(p.data.user.login) && + !reviewRequestedFrom(p, me); + // a snoozed pull stays off the board until tomorrow or its next + // change; the master reveal shows it like every other hidden group + return ( + hiddenRepo || hiddenPerson || cryoHidden || draftHidden || isSnoozed(p.data, snoozed) + ); + }, + [ + showAll, + reveal, + revealedRepo, + revealedAuthor, + hiddenRepos, + legacy, + me, + isBot, + settings.repoPrefs, + settings.showCryo, + settings.mutedPeople, + draftsMode, + snoozed, + ] + ); + // every existing scope/query pass, but not yet the Weight/State filters — // WeightFilter's live per-option counts read this pool, so narrowing to // one weight class doesn't make the other classes' counts vanish (the @@ -471,65 +526,15 @@ export function App() { const preWeightScoped = useMemo(() => { let out = pulls; if (legacy) out = applyLegacyFilters(out, legacy, me); - // Muted (user) and org-hidden repos, muted people, and cryo PRs stay off - // the board unless a session act reveals them: an explicit reveal, a - // scope, or a repo:/author: query term. Session-explicit beats the - // durable mute. Never hide your own pulls or bots — bots have their own - // fold, and a person can't mute themselves off their own board. - if (!showAll) - out = out.filter(p => { - const hiddenRepo = - repoHidden(p.data.repo, hiddenRepos, settings.repoPrefs) && - !revealedRepo(p.data.repo); - const hiddenPerson = - p.data.user.login !== me && - !isBot(p) && - personHidden(p.data.user.login, settings.mutedPeople) && - !revealedAuthor(p.data.user.login); - const cryoHidden = p.cryo && !settings.showCryo && !reveal.includes(CRYO_KEY); - // a snoozed pull stays off the board until tomorrow or its next - // change; the master reveal shows it like every other hidden group - return !hiddenRepo && !hiddenPerson && !cryoHidden && !isSnoozed(p.data, snoozed); - }); + out = out.filter(p => !boardHidden(p)); if (scope.repos.length) out = out.filter(p => scope.repos.includes(p.data.repo)); // bots bypass the people filter on purpose: dependency bumps need review // no matter whose work you follow (they land in the bots fold, not lanes) if (scope.authors.length) out = out.filter(p => isBot(p) || scope.authors.includes(p.data.user.login)); - // drafts: 'mine' keeps other people's drafts quiet on the general board, - // but not when you've deliberately looked (scoped/`author:`d that person) - // or when GitHub explicitly requested your review — a review-requested - // draft was vanishing even from the reviewer it was requested from, the - // reported bug. Your own drafts always show. The legacy path owns its own - // draft rule, so don't double-apply. - if (!legacy && draftsMode === 'mine') - out = out.filter( - p => - !p.data.draft || - p.data.user.login === me || - revealedAuthor(p.data.user.login) || - reviewRequestedFrom(p, me) - ); if (query) out = out.filter(p => matchesQuery(p, query, me)); return out; - }, [ - pulls, - scope, - query, - showAll, - reveal, - revealedRepo, - revealedAuthor, - hiddenRepos, - legacy, - me, - isBot, - settings.repoPrefs, - settings.showCryo, - settings.mutedPeople, - draftsMode, - snoozed, - ]); + }, [pulls, legacy, me, boardHidden, scope, isBot, query]); // preWeightScoped narrowed by the Weight filter, but not yet State — // StateFilter's own live counts read this pool for the same @@ -599,9 +604,37 @@ export function App() { extraBots, ]); - const cryoCount = pulls.filter(p => p.cryo).length; + // the hidden-PR ledger's numbers: stable per-category sizes (what each + // rule covers, whether or not a session reveal currently shows it) plus + // the live currently-hidden total for the trigger label + const hiddenCounts = useMemo(() => { + const c = { parked: 0, drafts: 0, mutedRepos: 0, mutedPeople: 0, snoozed: 0, hiddenNow: 0 }; + for (const p of pulls) { + if (p.cryo) c.parked++; + if (p.data.draft && p.data.user.login !== me && !reviewRequestedFrom(p, me)) c.drafts++; + if (repoHidden(p.data.repo, hiddenRepos, settings.repoPrefs)) c.mutedRepos++; + if ( + p.data.user.login !== me && + !isBot(p) && + personHidden(p.data.user.login, settings.mutedPeople) + ) + c.mutedPeople++; + if (isSnoozed(p.data, snoozed)) c.snoozed++; + if (boardHidden(p)) c.hiddenNow++; + } + return c; + }, [ + pulls, + me, + hiddenRepos, + settings.repoPrefs, + settings.mutedPeople, + isBot, + snoozed, + boardHidden, + ]); // counts every open pull a snooze currently hides, for the Settings surface - const snoozedCount = pulls.filter(p => isSnoozed(p.data, snoozed)).length; + const snoozedCount = hiddenCounts.snoozed; const isScoped = scope.repos.length || scope.authors.length || query; @@ -925,7 +958,6 @@ export function App() { toggleReveal={toggleReveal} showAll={showAll} setShowAll={setShowAll} - cryoCount={cryoCount} scope={scope} setScope={setScope} /> @@ -936,28 +968,33 @@ export function App() { setWeightSel={setWeightSel} /> - - + {sessionActive && ( + + )} {legacy && ( CI - + } def="CI wears the same marks as CR and QA. The machine is a reviewer too. A red ✗ disc means one or more checks failed (the popover says which); a gray-blue ring filling clockwise means the checks are still running, and the filled share is how many have finished. Passing shows nothing at rest. No news is good news; hover a row for its quiet green check and the per-check list" @@ -324,7 +324,7 @@ export function Legend() { /> } - def="the eye selector reveals off-by-default PRs: Cryogenic Storage and quiet repos" + def="the filter bar's “hidden” count names what the board holds back — parked (Cryogenic Storage) PRs, others' drafts, muted repos and people — and shows any of it for the session" /> diff --git a/frontend-v2/src/components/SavedFiltersPanel.tsx b/frontend-v2/src/components/SavedFiltersPanel.tsx index 480b7471..cbdf8c65 100644 --- a/frontend-v2/src/components/SavedFiltersPanel.tsx +++ b/frontend-v2/src/components/SavedFiltersPanel.tsx @@ -7,7 +7,7 @@ import { type KeyboardEvent, type RefObject, } from 'react'; -import { Bookmark, ChevronDown, Search, X } from 'lucide-react'; +import { Search, X } from 'lucide-react'; import { applySavedFilter, deleteFilter, @@ -18,6 +18,7 @@ import { type SavedFilter, } from '../model/savedFilters'; import { QuietButton } from './bits'; +import { FilterTrigger } from './filters/shared'; import { Icon } from './Icon'; import { Popover } from './Popover'; @@ -315,8 +316,8 @@ export function SavedFiltersInput({ * The header's "Saved ▾" trigger: only rendered once there's at least one * real saved filter (suggestions live in the query panel, not here — this is * the durable list, and an empty durable list has nothing to open into). - * Styled to match the other four filter triggers (RepoFilter, PeopleFilter, - * WeightFilter, StateFilter): h-8 pill, label hidden below sm. + * Wears the same quiet FilterTrigger the four dimension triggers wear, so + * the bar reads as one family. */ export function SavedFiltersMenu({ sessionActive }: { sessionActive: boolean }) { const saved = useSavedFilters(); @@ -330,17 +331,11 @@ export function SavedFiltersMenu({ sessionActive }: { sessionActive: boolean }) panelClass="max-h-[400px] overflow-auto p-2" rootClass="relative inline-flex items-center" trigger={t => ( - + )} > {saved.map(f => ( diff --git a/frontend-v2/src/components/filters/DraftsFilter.tsx b/frontend-v2/src/components/filters/DraftsFilter.tsx deleted file mode 100644 index 5a9fdc29..00000000 --- a/frontend-v2/src/components/filters/DraftsFilter.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { ChevronDown, Pencil } from 'lucide-react'; -import { setSettings } from '../../settings'; -import { QuietButton, Segmented } from '../bits'; -import { Icon } from '../Icon'; -import { Popover } from '../Popover'; - -/** - * The drafts toggle as a first-class filter control, sitting on the filter row - * beside Repos/People/Weight/State instead of buried under the Repos popover's - * "This session" fold. Binary (your own drafts always show; "all" adds everyone - * else's), a session override of the `draftsMode` default in Settings — so the - * trigger goes active only when it differs from that default. - */ -export function DraftsFilter({ - draftsMode, - setDraftsMode, - defaultMode, -}: { - draftsMode: 'mine' | 'all'; - setDraftsMode: (m: 'mine' | 'all') => void; - /** the Settings default this session value overrides */ - defaultMode: 'mine' | 'all'; -}) { - const active = draftsMode !== defaultMode; - const summary = active ? `Drafts · ${draftsMode}` : 'Drafts'; - - return ( -
- ( - - )} - > -
- Show drafts - -
- {draftsMode !== defaultMode && ( -
- setSettings({ draftsMode })}> - Make this my default - -
- )} -

- Your own drafts always show. “All” adds everyone else’s. -

-
-
- ); -} diff --git a/frontend-v2/src/components/filters/FilterChips.tsx b/frontend-v2/src/components/filters/FilterChips.tsx deleted file mode 100644 index 83fd1c77..00000000 --- a/frontend-v2/src/components/filters/FilterChips.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { X } from 'lucide-react'; -import type { ActionStateKey } from '../../model/actions'; -import { CRYO_KEY } from '../../model/visibility'; -import { useSettings } from '../../settings'; -import { Icon } from '../Icon'; - -interface Pill { - key: string; - label: string; - /** clears just this dimension */ - onClear: () => void; -} - -interface ActiveFilterArgs { - reveal: string[]; - showAll: boolean; - draftsMode: 'mine' | 'all'; - defaultDraftsMode: 'mine' | 'all'; - scope: { repos: string[]; authors: string[] }; - weightSel: string[]; - stateSel: ActionStateKey[]; -} - -/** - * Whether any TRANSIENT session filter is narrowing the board right now — - * what "Clear filters" below would reset, and what the saved-filters panel - * (SavedFiltersPanel.tsx) reads to decide whether there's anything worth - * bookmarking. One definition so the two surfaces can't disagree about what - * counts as "active". - */ -export function hasActiveFilters({ - reveal, - showAll, - draftsMode, - defaultDraftsMode, - scope, - weightSel, - stateSel, -}: ActiveFilterArgs): boolean { - return ( - scope.repos.length > 0 || - scope.authors.length > 0 || - weightSel.length > 0 || - stateSel.length > 0 || - reveal.length > 0 || - showAll || - draftsMode !== defaultDraftsMode - ); -} - -/** - * The header's chip row: one dismissible pill per ACTIVE session filter - * dimension, not per item — "3 people" reads at a glance where three separate - * name chips didn't. Durable state (mutes, stars) gets no chip: the Repos and - * People triggers already carry those counts in their own active labels, so a - * chip would just repeat the button next to it. - */ -export function FilterChips({ - reveal, - toggleReveal, - showAll, - setShowAll, - draftsMode, - setDraftsMode, - scope, - setScope, - weightSel, - setWeightSel, - stateSel, - setStateSel, - query, - setQuery, -}: { - reveal: string[]; - toggleReveal: (key: string) => void; - showAll: boolean; - setShowAll: (next: boolean) => void; - draftsMode: 'mine' | 'all'; - setDraftsMode: (m: 'mine' | 'all') => void; - scope: { repos: string[]; authors: string[] }; - setScope: (next: { repos: string[]; authors: string[] }) => void; - weightSel: string[]; - setWeightSel: (next: string[]) => void; - stateSel: ActionStateKey[]; - setStateSel: (next: ActionStateKey[]) => void; - /** the typed filter-box query: structured tokens in it (weight:xs, - * is:blocked, repo:…) are real filters that AND with the dropdowns — - * without a chip each, a dropdown + a forgotten token can silently - * empty the board with only one of the two culprits visible. */ - query: string; - setQuery: (next: string) => void; -}) { - const settings = useSettings(); - // an individually-revealed repo/cryo, outside full showAll — "hidden shown" - // covers both; cryo gets its own pill only when it's revealed on its own - // (showAll already says "everything," so a second cryo pill would be noise) - const revealedRepoCount = reveal.filter(k => k !== CRYO_KEY).length; - const cryoRevealedAlone = !showAll && reveal.includes(CRYO_KEY); - - const pills: Pill[] = []; - // one pill per structured token typed in the filter box, individually - // clearable — bare text terms are searches, not filters, and get none - const tokenRe = /^(weight|status|is|has|older|label|repo|author):.+$/; - for (const t of query.split(/\s+/).filter(x => tokenRe.test(x))) { - pills.push({ - key: `q:${t}`, - label: t, - onClear: () => - setQuery( - query - .split(/\s+/) - .filter(x => x !== t) - .join(' ') - ), - }); - } - if (scope.repos.length) - pills.push({ - key: 'repos', - label: `${scope.repos.length} repo${scope.repos.length > 1 ? 's' : ''}`, - onClear: () => setScope({ ...scope, repos: [] }), - }); - if (scope.authors.length) - pills.push({ - key: 'people', - label: `${scope.authors.length} ${scope.authors.length > 1 ? 'people' : 'person'}`, - onClear: () => setScope({ ...scope, authors: [] }), - }); - if (weightSel.length) - pills.push({ - key: 'weight', - label: `weight: ${weightSel.join(', ')}`, - onClear: () => setWeightSel([]), - }); - // a single selection spells itself out (matches StateFilter's own trigger - // label); more than one collapses to "first +N" so the pill can't grow as - // long as the option labels themselves - if (stateSel.length) - pills.push({ - key: 'state', - label: - stateSel.length === 1 - ? `state: ${stateSel[0]}` - : `state: ${stateSel[0]} +${stateSel.length - 1}`, - onClear: () => setStateSel([]), - }); - if (draftsMode === 'all' && draftsMode !== settings.draftsMode) - pills.push({ - key: 'drafts', - label: 'drafts: all', - onClear: () => setDraftsMode(settings.draftsMode), - }); - if (showAll || revealedRepoCount > 0) - pills.push({ - key: 'hidden', - label: 'hidden shown', - onClear: () => { - setShowAll(false); - for (const k of reveal) if (k !== CRYO_KEY) toggleReveal(k); - }, - }); - if (cryoRevealedAlone) - pills.push({ key: 'cryo', label: 'parked PRs shown', onClear: () => toggleReveal(CRYO_KEY) }); - - // what "Clear" would change — the transient filters, not your durable - // mutes and stars (those are your board; unmute/unstar them in the list) - const clearable = hasActiveFilters({ - reveal, - showAll, - draftsMode, - defaultDraftsMode: settings.draftsMode, - scope, - weightSel, - stateSel, - }); - - const clearAll = () => { - setScope({ repos: [], authors: [] }); - setWeightSel([]); - setStateSel([]); - setShowAll(false); - setDraftsMode(settings.draftsMode); - for (const k of reveal) toggleReveal(k); - }; - - if (!pills.length && !clearable) return null; - - return ( -
- {pills.map(p => ( - - {p.label} - - - ))} - {clearable && ( - - )} -
- ); -} diff --git a/frontend-v2/src/components/filters/HiddenPanel.tsx b/frontend-v2/src/components/filters/HiddenPanel.tsx new file mode 100644 index 00000000..0687f9dc --- /dev/null +++ b/frontend-v2/src/components/filters/HiddenPanel.tsx @@ -0,0 +1,209 @@ +import { ChevronDown, X } from 'lucide-react'; +import { CRYO_KEY } from '../../model/visibility'; +import { setSettings, useSettings } from '../../settings'; +import { QuietButton } from '../bits'; +import { Icon } from '../Icon'; +import { Popover } from '../Popover'; + +/** Per-category sizes of everything the board hides by default — computed in + * app.tsx beside the visibility filter itself so the two can't drift. */ +export interface HiddenCounts { + /** PRs labeled Cryogenic Storage */ + parked: number; + /** other people's drafts (yours always show) */ + drafts: number; + /** PRs in repos you muted or the org hides by default */ + mutedRepos: number; + /** PRs by people you muted */ + mutedPeople: number; + /** PRs you snoozed */ + snoozed: number; + /** PRs currently off the board, after session reveals */ + hiddenNow: number; +} + +/** + * The board's hidden-PR ledger: one quiet door at the end of the filter bar + * that always says how many open PRs you are NOT seeing, and opens into the + * why — parked, drafts, muted, snoozed — each with its count. The two + * categories with clean session toggles (parked, drafts) toggle right here; + * the durable ones say where their controls live instead of duplicating + * them. Replaces the parked-PRs checkbox that hid at the bottom of the Repos + * popover: what the board withholds is board-level information, not a repo + * setting, and a count you can always see is what makes hiding trustworthy. + */ +export function HiddenPanel({ + counts, + showAll, + setShowAll, + reveal, + toggleReveal, + draftsMode, + setDraftsMode, +}: { + counts: HiddenCounts; + showAll: boolean; + setShowAll: (next: boolean) => void; + reveal: string[]; + toggleReveal: (key: string) => void; + draftsMode: 'mine' | 'all'; + setDraftsMode: (m: 'mine' | 'all') => void; +}) { + const settings = useSettings(); + const parkedShown = settings.showCryo || reveal.includes(CRYO_KEY) || showAll; + const draftsShown = draftsMode === 'all' || showAll; + // session reveals in effect — the trigger flips to "showing hidden ×" so + // the undo is one click, mirroring how an active filter clears + const revealing = showAll || reveal.length > 0 || draftsMode !== settings.draftsMode; + const anythingHidden = + counts.parked + counts.drafts + counts.mutedRepos + counts.mutedPeople + counts.snoozed > 0; + if (!anythingHidden && !revealing) return null; + + const resetReveals = () => { + setShowAll(false); + for (const k of reveal) toggleReveal(k); + setDraftsMode(settings.draftsMode); + }; + + const toggleRow = ( + key: string, + label: string, + count: number, + checked: boolean, + disabled: boolean, + onToggle: () => void, + gloss: string, + makeDefault?: () => void + ) => ( +
+ +

{gloss}

+ {makeDefault && ( +
+ + Make this my default + +
+ )} +
+ ); + + const infoRow = (key: string, label: string, count: number, hint: string) => ( +
+
+ {label} + {count} +
+

{hint}

+
+ ); + + return ( + + ( + + )} + > + {counts.parked > 0 && + toggleRow( + 'parked', + 'Parked PRs', + counts.parked, + parkedShown, + settings.showCryo || showAll, + () => toggleReveal(CRYO_KEY), + 'Labeled Cryogenic Storage: long-running work, set aside on purpose.', + !settings.showCryo && reveal.includes(CRYO_KEY) + ? () => setSettings({ showCryo: true }) + : undefined + )} + {counts.drafts > 0 && + toggleRow( + 'drafts', + 'Drafts by others', + counts.drafts, + draftsShown, + showAll, + () => setDraftsMode(draftsMode === 'all' ? 'mine' : 'all'), + 'Your own drafts always show.', + draftsMode !== settings.draftsMode ? () => setSettings({ draftsMode }) : undefined + )} + {counts.mutedRepos > 0 && + infoRow( + 'muted-repos', + 'In repos you muted', + counts.mutedRepos, + 'Show or unmute them under Repos.' + )} + {counts.mutedPeople > 0 && + infoRow( + 'muted-people', + 'By people you muted', + counts.mutedPeople, + 'Unmute them under People.' + )} + {counts.snoozed > 0 && + infoRow( + 'snoozed', + 'Snoozed by you', + counts.snoozed, + 'Back tomorrow, or as soon as they change.' + )} +
+ +
+
+ {revealing && ( + + )} +
+ ); +} diff --git a/frontend-v2/src/components/filters/PeopleFilter.tsx b/frontend-v2/src/components/filters/PeopleFilter.tsx index fe1d7ea3..2334fc0e 100644 --- a/frontend-v2/src/components/filters/PeopleFilter.tsx +++ b/frontend-v2/src/components/filters/PeopleFilter.tsx @@ -1,13 +1,11 @@ import { useState } from 'react'; -import { ChevronDown, Users } from 'lucide-react'; import type { DerivedPull } from '../../model/status'; import { toggleMutedPerson, toggleStarredPerson, useSettings } from '../../settings'; import { usePulldasher } from '../../store'; import type { Team } from '../../types'; import { Avatar, QuietButton, StarMark } from '../bits'; -import { Icon } from '../Icon'; import { Popover } from '../Popover'; -import { FilterRow, FilterSearch, OnlyButton } from './shared'; +import { FilterRow, FilterSearch, FilterTrigger, OnlyButton } from './shared'; /** * The people filter: team preset chips, then a searchable author list where @@ -58,13 +56,14 @@ export function PeopleFilter({ }; const included = (login: string) => !scope.authors.length || scope.authors.includes(login); - const mutedCount = settings.mutedPeople.length; - const bits: string[] = []; - if (scope.authors.length) - bits.push(`${scope.authors.length} ${scope.authors.length > 1 ? 'people' : 'person'}`); - if (mutedCount) bits.push(`${mutedCount} muted`); - const active = bits.length > 0; - const summary = bits.length ? bits.join(' · ') : 'People'; + // the trigger names only what narrows the board: one person by login, + // more as a count. Mute counts read in the hidden-PR ledger, not here. + const value = + scope.authors.length === 0 + ? null + : scope.authors.length === 1 + ? scope.authors[0] + : `${scope.authors.length} people`; const filteredShown = shownAuthors.filter(([login]) => login.toLowerCase().includes(peopleQuery.toLowerCase()) @@ -81,21 +80,13 @@ export function PeopleFilter({ panelClass="max-h-[460px] overflow-auto p-2" rootClass="relative inline-flex items-center" trigger={t => ( - + setScope({ ...scope, authors: [] })} + ariaLabel={`people filter: ${value ?? 'off'}`} + /> )} > {teams.length > 0 && ( diff --git a/frontend-v2/src/components/filters/RepoFilter.tsx b/frontend-v2/src/components/filters/RepoFilter.tsx index 5759d07d..6116193d 100644 --- a/frontend-v2/src/components/filters/RepoFilter.tsx +++ b/frontend-v2/src/components/filters/RepoFilter.tsx @@ -1,12 +1,10 @@ import { useState } from 'react'; -import { ChevronDown, Tag } from 'lucide-react'; -import { CRYO_KEY, repoState } from '../../model/visibility'; +import { repoState } from '../../model/visibility'; import { shortRepo } from '../../format'; -import { setRepoPref, setSettings, togglePrimaryRepo, useSettings } from '../../settings'; +import { setRepoPref, togglePrimaryRepo, useSettings } from '../../settings'; import { QuietButton, StarMark } from '../bits'; -import { Icon } from '../Icon'; import { Popover } from '../Popover'; -import { FilterSearch, OnlyButton } from './shared'; +import { FilterSearch, FilterTrigger, OnlyButton } from './shared'; /** * The repos filter: opens straight into the repo list (no tabs — the old @@ -14,10 +12,10 @@ import { FilterSearch, OnlyButton } from './shared'; * you had to make before you could even search). Scope (which repos are on * your board right now) and mute (which repos are off your board, period) * live side by side on each row, plus the ★ star that marks a repo you - * actively review (drives the review queue's primary/other split). The cryo - * session toggle rides along at the bottom under a "This session" label, so it - * reads as visibly less durable than the rows above it. (Drafts used to sit - * here too; it's now its own control on the filter row — see DraftsFilter.) + * actively review (drives the review queue's primary/other split). (Parked + * PRs and drafts used to ride along here as session toggles; they're + * board-level hiding, so they live in the filter bar's hidden-PR ledger now — + * see HiddenPanel.) */ export function RepoFilter({ repos, @@ -26,11 +24,9 @@ export function RepoFilter({ toggleReveal, showAll, setShowAll, - cryoCount, scope, setScope, }: { - /** exposes the trigger button to FilterChips' durable "muted repos" pill */ /** all repos with open-PR counts (org-hidden and muted included) */ repos: { name: string; count: number }[]; orgHidden: ReadonlySet; @@ -38,7 +34,6 @@ export function RepoFilter({ toggleReveal: (key: string) => void; showAll: boolean; setShowAll: (next: boolean) => void; - cryoCount: number; scope: { repos: string[]; authors: string[] }; setScope: (next: { repos: string[]; authors: string[] }) => void; }) { @@ -49,7 +44,6 @@ export function RepoFilter({ const shownRepos = repos.filter(r => repoState(r.name, orgHidden, prefs) === 'shown'); const hiddenRepos = repos.filter(r => repoState(r.name, orgHidden, prefs) !== 'shown'); - const mutedCount = repos.filter(r => repoState(r.name, orgHidden, prefs) === 'muted').length; const commit = (next: string[], all: string[]) => setScope({ ...scope, repos: all.length && next.length === all.length ? [] : next }); @@ -62,14 +56,15 @@ export function RepoFilter({ }; const included = (name: string) => !scope.repos.length || scope.repos.includes(name); - // the trigger summary: repos you've narrowed to, plus how many you've - // muted — drafts/hidden/cryo now live in FilterChips, not here - const bits: string[] = []; - if (scope.repos.length) - bits.push(`${scope.repos.length} repo${scope.repos.length > 1 ? 's' : ''}`); - if (mutedCount) bits.push(`${mutedCount} muted`); - const active = bits.length > 0; - const summary = bits.length ? bits.join(' · ') : 'Repos'; + // the trigger names only what narrows the board: a lone repo by name, more + // as a count. Mute counts are durable state — they read in the hidden-PR + // ledger, not here, so the trigger stays quiet when nothing is filtered. + const value = + scope.repos.length === 0 + ? null + : scope.repos.length === 1 + ? shortRepo(scope.repos[0]) + : `${scope.repos.length} repos`; const shownRow = (name: string, count: number) => { const isPrimary = primary.has(name); @@ -165,21 +160,13 @@ export function RepoFilter({ panelClass="max-h-[460px] overflow-auto p-2" rootClass="relative inline-flex items-center" trigger={t => ( - + setScope({ ...scope, repos: [] })} + ariaLabel={`repos filter: ${value ?? 'off'}`} + /> )} > @@ -214,39 +201,6 @@ export function RepoFilter({ {filteredShown.length === 0 && (
No repos match.
)} - - {cryoCount > 0 && - (() => { - const cryoSessionOn = settings.showCryo || reveal.includes(CRYO_KEY) || showAll; - return ( -
-
- This session -
- - {cryoSessionOn !== settings.showCryo && ( -
- setSettings({ showCryo: cryoSessionOn })} - > - Make this my default - -
- )} -
- ); - })()}
); diff --git a/frontend-v2/src/components/filters/StateFilter.tsx b/frontend-v2/src/components/filters/StateFilter.tsx index 9b7821ba..e7c90f12 100644 --- a/frontend-v2/src/components/filters/StateFilter.tsx +++ b/frontend-v2/src/components/filters/StateFilter.tsx @@ -1,11 +1,9 @@ -import { ChevronDown, ClipboardList } from 'lucide-react'; import type { ActionStateKey } from '../../model/actions'; import { actionState } from '../../model/actions'; import type { DerivedPull } from '../../model/status'; import { usePulldasher } from '../../store'; -import { Icon } from '../Icon'; import { Popover } from '../Popover'; -import { FilterRow, OnlyButton } from './shared'; +import { FilterRow, FilterTrigger, OnlyButton } from './shared'; /** * State filter option order: what's waiting on you first (the thing you're @@ -51,13 +49,15 @@ export function StateFilter({ setStateSel(next); }; - const active = stateSel.length > 0; - const summary = + // one selection names itself; more collapse to "first +N" so the trigger + // can't grow as long as the option labels themselves + const first = STATE_OPTIONS.find(o => o.key === stateSel[0])?.label ?? stateSel[0]; + const value = stateSel.length === 0 - ? 'State' + ? null : stateSel.length === 1 - ? `State · ${STATE_OPTIONS.find(o => o.key === stateSel[0])?.label ?? stateSel[0]}` - : `State · ${stateSel.length} selected`; + ? first + : `${first} +${stateSel.length - 1}`; return (
@@ -67,21 +67,13 @@ export function StateFilter({ panelClass="p-2" rootClass="relative inline-flex items-center" trigger={t => ( - + setStateSel([])} + ariaLabel={`state filter: ${value ?? 'off'}`} + /> )} > {STATE_OPTIONS.map(({ key, label }) => ( diff --git a/frontend-v2/src/components/filters/WeightFilter.tsx b/frontend-v2/src/components/filters/WeightFilter.tsx index fe12bb65..8662f751 100644 --- a/frontend-v2/src/components/filters/WeightFilter.tsx +++ b/frontend-v2/src/components/filters/WeightFilter.tsx @@ -1,9 +1,7 @@ -import { ChevronDown, Weight as WeightIcon } from 'lucide-react'; import type { DerivedPull } from '../../model/status'; import { weightFilterKey } from '../../model/status'; -import { Icon } from '../Icon'; import { Popover } from '../Popover'; -import { FilterRow, OnlyButton } from './shared'; +import { FilterRow, FilterTrigger, OnlyButton } from './shared'; /** Weight filter option order: lightest to heaviest, then the unknown-size * catch-all — mirrors the rail's own weight-letter read (XS through XL). */ @@ -43,8 +41,10 @@ export function WeightFilter({ setWeightSel(next); }; - const active = weightSel.length > 0; - const summary = active ? `Weight · ${weightSel.join(', ')}` : 'Weight'; + // the rail spells weights as letters (XS…XL); the trigger matches it + const value = weightSel.length + ? weightSel.map(k => (k === 'unknown' ? 'unknown' : k.toUpperCase())).join(', ') + : null; return (
@@ -54,21 +54,13 @@ export function WeightFilter({ panelClass="p-2" rootClass="relative inline-flex items-center" trigger={t => ( - + setWeightSel([])} + ariaLabel={`weight filter: ${value ?? 'off'}`} + /> )} > {WEIGHT_OPTIONS.map(({ key, label }) => ( diff --git a/frontend-v2/src/components/filters/shared.tsx b/frontend-v2/src/components/filters/shared.tsx index 2bd6be12..de7d29c3 100644 --- a/frontend-v2/src/components/filters/shared.tsx +++ b/frontend-v2/src/components/filters/shared.tsx @@ -1,4 +1,100 @@ import type { ChangeEvent, ReactNode } from 'react'; +import { ChevronDown, X } from 'lucide-react'; +import { Icon } from '../Icon'; +import type { PopoverTriggerProps } from '../Popover'; + +/** + * The one filter-bar trigger: a quiet text-level control, not a bordered + * button. The bar's normal state is "a few filters always on", so an active + * filter can't wear highlight chrome — the whole ladder is typographic: + * inactive = muted word, active = the dimension's VALUE named in ink with a + * small clear ×. The trigger is the chip; there is no second pill restating + * it elsewhere. (No leading icons either: six pictograms said less than the + * six words, and cost a row of decoding.) + */ +export function FilterTrigger({ + t, + label, + value, + onClear, + ariaLabel, +}: { + t: PopoverTriggerProps; + /** the dimension name, always visible: Repos, People, Weight… */ + label: string; + /** the active selection, named ("XS, S"); null/empty = inactive */ + value?: string | null; + /** clears just this dimension (renders the × beside the trigger) */ + onClear?: () => void; + ariaLabel: string; +}) { + return ( + + + {value && onClear && ( + + )} + + ); +} + +/** + * Whether any TRANSIENT session filter is narrowing (or revealing) the board + * right now — what the bar's "Reset" resets, and what the saved-filters + * panel reads to decide whether there's anything worth bookmarking. One + * definition so the two surfaces can't disagree about what counts as + * "active". Durable state (mutes, stars, defaults) is deliberately not here. + */ +export function hasActiveFilters({ + reveal, + showAll, + draftsMode, + defaultDraftsMode, + scope, + weightSel, + stateSel, +}: { + reveal: string[]; + showAll: boolean; + draftsMode: 'mine' | 'all'; + defaultDraftsMode: 'mine' | 'all'; + scope: { repos: string[]; authors: string[] }; + weightSel: string[]; + stateSel: string[]; +}): boolean { + return ( + scope.repos.length > 0 || + scope.authors.length > 0 || + weightSel.length > 0 || + stateSel.length > 0 || + reveal.length > 0 || + showAll || + draftsMode !== defaultDraftsMode + ); +} /** * The one filter-panel search input, replacing the three copy-pasted From 59f3bfcb296d9b9f0e0caabb5a869f5ff6377f5c Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 13:13:12 -0700 Subject: [PATCH 266/381] Make the action machine truthful: bots, dismissals, masks, stacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of the action/state machine against the live board (82 open PRs) found four places the imperatives lied, one of them on a quarter of the board: 1. "Answer the review" fired on bot reviews. The wire's unstamped_reviewers carries every reviewer, and the CI review bot COMMENTs on most PRs — 21 of 45 open non-draft PRs had a bot COMMENTED review, and on 11 the bot was the only unstamped reviewer, so the author's card read "Answer the review · from claude[bot]", grouped under Respond, bucketed "Waiting on me", and the branch's early return buried the real queue-position / nudge context. Filter bots (same suffix check engagedNoStamp already uses). 2. DISMISSED reviews demanded answers. A dismissed review no longer stands, so it owes nothing; live examples on #61656/#63206/#62457 were all nagging their authors. Drop DISMISSED from the answer-the-review set. 3. Re-stamp / Finish QA / Re-QA ignored the masking statuses. The personal-obligation early exits ran before the draft / dev_block / ci_red branches, contradicting the machine's own precedence: a pull converted back to draft told its stale stampers "Re-stamp" (truth: not reviewable), a dev-blocked pull asked for re-stamps while the author still owed changes, and the push that broke CI demanded a re-review of a head the author is about to replace. New authorOwnsIt gate covers reviewerMove (desktop nudges), reviewerNote (cards), actionState (the State filter), is:restamp (the query token), and the Team/People owes-re-stamps maps, so every surface agrees: while the author owns the pull, reviewers are asked for nothing, and the obligation returns when the mask lifts. QA's parallel-with-CR behavior across the OTHER statuses is unchanged. 4. A clean stacked PR could get a "Rebase" nudge. authorMove keyed on status === 'unmergeable', which also covers dependent-without- conflict — signed off, waiting on its parent, nothing to rebase. Key on the conflict flag alone; authorNote already said "lands with its parent" correctly. actionState now derives the restamp bucket from the note's action instead of raw recrBy/reqaBy, so the filter count can never disagree with the word on the card (this also fixes the edge where a re-tester holding the QAing label bucketed "Re-stamp owed" while reading "Finish QA"). The masking rule is documented in the Legend's Sign-offs group. Six new tests pin the gate, the bot/DISMISSED filters, and the stacked no-rebase case; two existing tests that pinned the old unmergeable → Rebase and ci_red → Re-stamp behavior are updated to assert the fix. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Legend.tsx | 4 + frontend-v2/src/model/actions.test.ts | 108 ++++++++++++++++++++++++-- frontend-v2/src/model/actions.ts | 64 ++++++++++++--- frontend-v2/src/model/query.test.ts | 11 +++ frontend-v2/src/model/query.ts | 9 ++- frontend-v2/src/views/People.tsx | 3 +- frontend-v2/src/views/Team.tsx | 3 +- 7 files changed, 180 insertions(+), 22 deletions(-) diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 8f487c62..b6373116 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -105,6 +105,10 @@ export function Legend() { } def="the dotted underline marks a slot you stamped" /> + Re-stamp} + def="a push undid your stamp and the board is asking again — but only once the PR is reviewable: while it's a draft, dev-blocked, or CI is red, the next move is the author's and no re-stamp is asked of you yet" + /> is testing it} def="someone claimed QA by adding the QAing label on GitHub" diff --git a/frontend-v2/src/model/actions.test.ts b/frontend-v2/src/model/actions.test.ts index 58da158b..92c72a90 100644 --- a/frontend-v2/src/model/actions.test.ts +++ b/frontend-v2/src/model/actions.test.ts @@ -69,7 +69,17 @@ describe('alertMove — which transitions earn a desktop nudge', () => { expect( alertMove(dp({ author: 'me', status: 'dev_block', devBlockedBy: ['reviewer'] }), 'me') ).toBe('Address feedback'); - expect(alertMove(dp({ author: 'me', status: 'unmergeable' }), 'me')).toBe('Rebase'); + expect(alertMove(dp({ author: 'me', status: 'unmergeable', conflict: true }), 'me')).toBe( + 'Rebase' + ); + }); + + it('does not tell a clean stacked PR to rebase', () => { + // unmergeable also covers dependent-without-conflict: signed off and + // waiting on its parent — nothing to rebase, so no nudge + expect( + alertMove(dp({ author: 'me', status: 'unmergeable', dependent: true }), 'me') + ).toBeNull(); }); it('does not nudge you about a block you put on your own PR', () => { @@ -120,16 +130,23 @@ describe('authorMove — a dev block you hold yourself', () => { }); }); -describe('reviewerMove — re-stamp/re-QA are not gated on the current status', () => { +describe('reviewerMove — re-stamp/re-QA across statuses, gated on authorOwnsIt', () => { it('surfaces an owed Re-QA even when the pull sits at needs_recr', () => { // a push invalidated both your QA and someone else's CR at once: status // lands at needs_recr (CR precedes QA), but the re-QA is still yours to do const p = dp({ author: 'a', status: 'needs_recr', recrBy: ['alice'], reqaBy: ['me'] }); expect(reviewerMove(p, 'me')).toBe('Re-QA'); }); - it('surfaces an owed Re-stamp whatever higher-precedence state masks it', () => { - const p = dp({ author: 'a', status: 'ci_red', recrBy: ['me'] }); - expect(reviewerMove(p, 'me')).toBe('Re-stamp'); + it('asks for nothing while the author owns the pull', () => { + // the push that broke CI is the same push that staled your stamp: the + // author fixes first, and the head you would re-review is about to + // change. Same for a draft (not reviewable) and a dev block (more + // pushes coming) — the re-obligations come back once the mask lifts. + for (const status of ['ci_red', 'draft', 'dev_block'] as const) { + expect(reviewerMove(dp({ author: 'a', status, recrBy: ['me'] }), 'me')).toBeNull(); + expect(reviewerMove(dp({ author: 'a', status, reqaBy: ['me'] }), 'me')).toBeNull(); + expect(reviewerMove(dp({ author: 'a', status, qaingLogin: 'me' }), 'me')).toBeNull(); + } }); }); @@ -241,6 +258,46 @@ describe('rowNote — the author matrix', () => { ).toEqual({ action: 'Answer the review', context: 'from holly' }); }); + it('needs_cr: bot reviews never demand an answer', () => { + // the CI review bot COMMENTs on most PRs; unfiltered, "Answer the + // review · from claude[bot]" was the note on a quarter of the live board + expect( + note( + { + status: 'needs_cr', + unstampedReviewers: [{ login: 'claude[bot]', state: 'COMMENTED', date: 1 }], + }, + me + ) + ).toEqual({ action: null, context: 'in the CR queue' }); + }); + + it('needs_cr: a dismissed review no longer stands, so it owes no answer', () => { + expect( + note( + { + status: 'needs_cr', + unstampedReviewers: [{ login: 'holly', state: 'DISMISSED', date: 1 }], + }, + me + ) + ).toEqual({ action: null, context: 'in the CR queue' }); + // a human verdict beside them still asks + expect( + note( + { + status: 'needs_cr', + unstampedReviewers: [ + { login: 'claude[bot]', state: 'COMMENTED', date: 1 }, + { login: 'holly', state: 'DISMISSED', date: 2 }, + { login: 'iris', state: 'COMMENTED', date: 3 }, + ], + }, + me + ) + ).toEqual({ action: 'Answer the review', context: 'from iris' }); + }); + it('needs_cr: comment-only engagement (no reviewer) is a discussion wait', () => { expect(note({ status: 'needs_cr', engagedNoStamp: ['iris'] }, me)).toEqual({ action: null, @@ -354,6 +411,26 @@ describe('rowNote — the non-author matrix', () => { }); }); + it('your stale stamp asks nothing while the author owns the pull', () => { + // the same push that staled your stamp also broke CI / the pull went + // back to draft / a dev block landed: the head you would re-review is + // about to change, so the note is the wait, not "Re-stamp" + expect(note({ author, status: 'ci_red', recrBy: ['me'] }, me)).toEqual({ + action: null, + context: 'CI red · author fixes', + }); + expect(note({ author, status: 'draft', recrBy: ['me'] }, me)).toEqual({ + action: null, + context: 'draft, not reviewable yet', + }); + expect( + note({ author, status: 'dev_block', devBlockedBy: ['bob'], reqaBy: ['me'] }, me) + ).toEqual({ + action: null, + context: 'feedback from bob', + }); + }); + it('draft', () => { expect(note({ author, status: 'draft' }, me)).toEqual({ action: null, @@ -579,6 +656,27 @@ describe('actionState — one bucket per (pull, viewer)', () => { ); }); + it('restamp follows the note, not raw recrBy/reqaBy', () => { + // author owns it: the stale stamp asks nothing yet, so the bucket is + // the wait/blocked one the card actually shows + expect(actionState(dp({ author: 'auth', status: 'ci_red', recrBy: ['me'] }), 'me')).toBe( + 'waiting' + ); + expect( + actionState( + dp({ author: 'auth', status: 'dev_block', devBlockedBy: ['bob'], recrBy: ['me'] }), + 'me' + ) + ).toBe('blocked'); + // a re-tester holding the QAing label reads "Finish QA" → mine + expect( + actionState( + dp({ author: 'auth', status: 'needs_qa', qaingLogin: 'me', reqaBy: ['me'] }), + 'me' + ) + ).toBe('mine'); + }); + it('review: an unreviewed pull, viewer not the author', () => { expect(actionState(dp({ author: 'auth', status: 'needs_cr' }), 'me')).toBe('review'); }); diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index 62b114e2..1323cc86 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -1,6 +1,21 @@ import { ago } from '../format'; import { requestedReviewers } from './reviewers'; import type { DerivedPull } from './status'; +import { isBotLogin } from './visibility'; + +/** + * The three statuses that put the ball wholly with the author — while one + * holds, the board asks reviewers for NOTHING, not even an owed re-stamp: a + * draft isn't reviewable, a dev block means more pushes are coming, and red + * CI means the head everyone would re-review is about to change. Gates the + * personal-obligation checks (Re-stamp / Finish QA / Re-QA) in reviewerMove, + * reviewerNote, and the `is:restamp` query token, so a stale stamp only asks + * for its re-stamp once the pull is reviewable again. (QA obligations stay + * un-gated across the OTHER statuses on purpose — QA runs in parallel with + * CR on this board.) + */ +export const authorOwnsIt = (p: DerivedPull): boolean => + p.status === 'draft' || p.status === 'dev_block' || p.status === 'ci_red'; /** * The verb column: what moves this pull, and whose move is it? Review's @@ -22,7 +37,10 @@ export function authorMove(p: DerivedPull): string | null { const others = p.devBlockedBy.filter(l => l !== p.data.user.login); return others.length ? 'Address feedback' : 'Lift your block'; } - if (p.status === 'unmergeable' || p.conflict) return 'Rebase'; + // conflict only, NOT status === 'unmergeable': that status also covers a + // clean stacked pull (dependent, signed off, waiting on its parent), where + // there is nothing to rebase and no move to nudge about + if (p.conflict) return 'Rebase'; if (p.status === 'needs_qa' && !p.qaingLogin && !p.reqaBy.length) return 'Find a QA-er'; if (p.status === 'draft') return 'Finish the draft'; return null; @@ -33,9 +51,12 @@ export function authorMove(p: DerivedPull): string | null { * CR on this board (see Review.tsx's qaPool), so a push that invalidates both * your QA and someone's CR at once lands the pull at needs_recr while still * owing you a re-QA — and rowNote/actionState already surface it, so "Yours to - * do" must too, or the card shows "Re-QA" while the lane silently drops it. */ + * do" must too, or the card shows "Re-QA" while the lane silently drops it. + * The one gate is authorOwnsIt: a draft / dev-blocked / red-CI pull asks + * reviewers for nothing until the author's move lands. */ export function reviewerMove(p: DerivedPull, me: string): string | null { if (p.data.user.login === me) return null; + if (authorOwnsIt(p)) return null; if (p.recrBy.includes(me)) return 'Re-stamp'; if (p.qaingLogin === me) return 'Finish QA'; if (p.reqaBy.includes(me)) return 'Re-QA'; @@ -144,8 +165,17 @@ function authorNote(p: DerivedPull, me: string): RowNote { return waitOnly(`waiting on ${who(p.recrBy)} to re-stamp${pushed}`); if (p.status === 'needs_cr') { // any reviewer with an unstamped verdict (CHANGES_REQUESTED already - // handled above) has left something the author owes an answer to - const reviewerLogins = unique((d.status.unstamped_reviewers ?? []).map(r => r.login)); + // handled above) has left something the author owes an answer to. + // DISMISSED is excluded — a dismissed review no longer stands, so it + // owes no answer. Bots are excluded too: the wire's unstamped_reviewers + // carries every reviewer, and the CI review bot COMMENTs on most PRs — + // unfiltered, "Answer the review · from claude[bot]" was the note on a + // quarter of the live board, burying the real queue-position context. + const reviewerLogins = unique( + (d.status.unstamped_reviewers ?? []) + .filter(r => r.state !== 'DISMISSED' && !isBotLogin(r.login, new Set())) + .map(r => r.login) + ); if (reviewerLogins.length) return { action: 'Answer the review', context: `from ${who(reviewerLogins)}` }; if (p.engagedNoStamp.length) return waitOnly(`in discussion with ${who(p.engagedNoStamp)}`); @@ -269,13 +299,19 @@ function reviewerNote( const requestedFromMe = requested.includes(me); const requestedOthers = requested.filter(l => l !== me); - if (p.recrBy.includes(me)) - return { - action: 'Re-stamp', - context: p.headPushedAt ? `last commit ${ago(p.headPushedAt)} ago` : null, - }; - if (p.qaingLogin === me) return doOnly('Finish QA'); - if (p.reqaBy.includes(me)) return doOnly('Re-QA'); + // your personal obligations — but only once the pull is reviewable: while + // the author owns it (draft / dev block / red CI, the statuses below that + // return early anyway) a stale stamp asks nothing of you yet, or "Re-stamp" + // would nag you about a head the author is still about to replace + if (!authorOwnsIt(p)) { + if (p.recrBy.includes(me)) + return { + action: 'Re-stamp', + context: p.headPushedAt ? `last commit ${ago(p.headPushedAt)} ago` : null, + }; + if (p.qaingLogin === me) return doOnly('Finish QA'); + if (p.reqaBy.includes(me)) return doOnly('Re-QA'); + } if (p.status === 'draft') return waitOnly('draft, not reviewable yet'); if (p.status === 'ci_red') return waitOnly('CI red · author fixes'); @@ -355,8 +391,12 @@ function reviewerNote( export type ActionStateKey = 'restamp' | 'review' | 'qa' | 'mine' | 'blocked' | 'waiting'; export function actionState(p: DerivedPull, me: string): ActionStateKey { - if (p.recrBy.includes(me) || p.reqaBy.includes(me)) return 'restamp'; + // keyed off the note's action, not raw recrBy/reqaBy, so the bucket can + // never disagree with the word on the card: a stale stamp on a pull the + // author still owns (authorOwnsIt) isn't a re-stamp yet, and a re-tester + // who holds the QAing label reads "Finish QA", which is `mine` const note = rowNote(p, me); + if (note.action === 'Re-stamp' || note.action === 'Re-QA') return 'restamp'; if (note.action === 'Review it' || note.action === 'Re-review') return 'review'; if (note.action === 'QA it') return 'qa'; if (note.action != null) return 'mine'; diff --git a/frontend-v2/src/model/query.test.ts b/frontend-v2/src/model/query.test.ts index 3b4ff995..d2c1d204 100644 --- a/frontend-v2/src/model/query.test.ts +++ b/frontend-v2/src/model/query.test.ts @@ -115,6 +115,17 @@ describe('matchesQuery', () => { expect(matchesQuery(p, 'is:restamp', 'dave')).toBe(false); }); + it('is:restamp asks nothing while the author owns the pull', () => { + // same gate as reviewerMove: a draft / dev-blocked / red-CI pull owes + // no re-stamp until the author's move lands + expect(matchesQuery(fake({ status: 'ci_red', recrBy: ['bob'] }), 'is:restamp', 'bob')).toBe( + false + ); + expect(matchesQuery(fake({ status: 'draft', reqaBy: ['bob'] }), 'is:restamp', 'bob')).toBe( + false + ); + }); + it('is:blocked matches dev_block and deploy_block statuses only', () => { expect(matchesQuery(fake({ status: 'dev_block' }), 'is:blocked', 'viewer')).toBe(true); expect(matchesQuery(fake({ status: 'deploy_block' }), 'is:blocked', 'viewer')).toBe(true); diff --git a/frontend-v2/src/model/query.ts b/frontend-v2/src/model/query.ts index a12121d4..4560ee94 100644 --- a/frontend-v2/src/model/query.ts +++ b/frontend-v2/src/model/query.ts @@ -1,4 +1,4 @@ -import { rowNote } from './actions'; +import { authorOwnsIt, rowNote } from './actions'; import type { DerivedPull } from './status'; /** @@ -13,7 +13,7 @@ import type { DerivedPull } from './status'; * author:al author login contains * weight:xs,s review-effort class, comma list ORs (weight:xs,s = XS or S) * has:action the viewer (`me`) has an imperative move on this card - * is:restamp `me` owes a re-CR or re-QA (in recrBy/reqaBy) + * is:restamp `me` owes a re-CR or re-QA on a reviewable pull * is:blocked status is dev_block or deploy_block * * `me` is the viewer's login, needed only for has:/is: — every other token @@ -43,7 +43,10 @@ function matchTerm(p: DerivedPull, term: string, me: string): boolean { return val.split(',').filter(Boolean).includes(p.weight.toLowerCase()); if (key === 'has') return val === 'action' && rowNote(p, me).action != null; if (key === 'is') { - if (val === 'restamp') return p.recrBy.includes(me) || p.reqaBy.includes(me); + // same authorOwnsIt gate as reviewerMove: no re-stamp is owed + // while the pull is a draft, dev-blocked, or red-CI + if (val === 'restamp') + return !authorOwnsIt(p) && (p.recrBy.includes(me) || p.reqaBy.includes(me)); if (val === 'blocked') return p.status === 'dev_block' || p.status === 'deploy_block'; return false; } diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx index ab675a6e..3ce59ebd 100644 --- a/frontend-v2/src/views/People.tsx +++ b/frontend-v2/src/views/People.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { authorOwnsIt } from '../model/actions'; import { STATUS_ORDER, type DerivedPull } from '../model/status'; import { useSettings } from '../settings'; import type { Team } from '../types'; @@ -41,7 +42,7 @@ export function People({ for (const p of allPulls) counts.set(p.data.user.login, (counts.get(p.data.user.login) ?? 0) + 1); const owes = new Map(); - for (const p of allPulls) for (const u of p.recrBy) owes.set(u, [...(owes.get(u) ?? []), p]); + for (const p of allPulls.filter(x => !authorOwnsIt(x))) for (const u of p.recrBy) owes.set(u, [...(owes.get(u) ?? []), p]); // starred people lead the directory; muted ones drop out entirely unless // they're the person an explicit pick (a URL or a click) already landed on diff --git a/frontend-v2/src/views/Team.tsx b/frontend-v2/src/views/Team.tsx index e3dbbd18..00776e3b 100644 --- a/frontend-v2/src/views/Team.tsx +++ b/frontend-v2/src/views/Team.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { authorOwnsIt } from '../model/actions'; import type { DerivedPull } from '../model/status'; import { matchesRegion } from '../model/regions'; import { crSort } from '../model/sort'; @@ -46,7 +47,7 @@ export function Team({ }, [allPulls]); const owes = useMemo(() => { const m = new Map(); - for (const p of allPulls) for (const u of p.recrBy) m.set(u, [...(m.get(u) ?? []), p]); + for (const p of allPulls.filter(x => !authorOwnsIt(x))) for (const u of p.recrBy) m.set(u, [...(m.get(u) ?? []), p]); return m; }, [allPulls]); From 59d76f391f470c02b9d27614640721af4d5db814 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 13:21:20 -0700 Subject: [PATCH 267/381] Legend as a conventions card; per-mark rules move to their marks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legend had grown into a 380px encyclopedia: seven groups, twenty-odd items, four multi-paragraph fold-out essays (weight, regions, claims, turn rotation), ~2,600px of scroll. Most of it restated things a mark already explains in place, and its illustration glyphs (✦, ✋, a v1 eye icon) didn't even match the marks actually on the board. The redesign follows locality of information — the lane-subtitle popover pattern: an explanation lives where the thing it explains sits, and the legend keeps only what no single mark can teach. Moved to their marks: - The pip vocabulary keys itself inside the sign-off ledger popover (stands / staled by a push / needed), right where the pips are read. - How weight is decided (label wins, else the 50/150/600/1500 diff steps, +1 class past 15 files) lives in the weight letter's popover, replacing the "from diff size" stub and the legend essay. - The claim button's own title now carries the essential fact: claiming adds you as a reviewer on the PR itself. (The restart-timestamp trivia from the old essay is cut, not moved — server arcana.) - The re-stamp masking rule glosses the wait-words where a masked card actually sits: "CI red" and "blocked" headers now say stale stamps wait for the author's fix. "in QA" now says how QA is claimed (the QAing label) — the only how-to the old legend held exclusively. - Age, the hidden ledger, group-header words, and regions already explain themselves in place; their legend entries are deleted, not moved. What remains is one screen, five groups, no folds: the invented word (stamp — now correctly including GitHub approvals, not just comments), the circle-mark family with the author-moves-first rule, CI's same-marks convention (the one thing a mark can't teach about itself: passing is invisible at rest), the color grammar (brand = your move, amber = act, gray = fact), the three coordination signals shown as the REAL marks (the lucide hand, the "requested from you" words), the four query tokens, and the keys. A lead-in line sets the contract: hover anything, everything explains itself where it sits. 420px wide so definitions wrap at two to three lines; ~980px tall against the old ~2,600px. The bundle drops ~8kB of essay prose. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Legend.tsx | 276 ++++------------------ frontend-v2/src/components/Row.tsx | 10 +- frontend-v2/src/components/WordGroups.tsx | 9 +- frontend-v2/src/components/bits.tsx | 22 +- 4 files changed, 72 insertions(+), 245 deletions(-) diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index b6373116..b5f5661c 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -1,7 +1,5 @@ import type { ReactNode } from 'react'; -import { ChevronRight, CircleHelp, Diamond } from 'lucide-react'; -import { useSettings } from '../settings'; -import { DiffSize } from './bits'; +import { CircleHelp, Hand } from 'lucide-react'; import { Icon } from './Icon'; import { Popover } from './Popover'; import { eyebrowText } from './WordGroups'; @@ -42,141 +40,76 @@ function Group({ title, children }: { title: string; children: ReactNode }) { } /** - * The one-stop decoder for the board's invented vocabulary. New hires can't - * learn "stamp" or the slashed pip from hover titles alone — this is the - * visible answer the review pass found missing. Grouped so it reads as a - * key, not a glossary dump: sign-offs, then the row anatomy, then badges, - * then catching up, then keys. + * The conventions card, not an encyclopedia. Everything tied to one mark + * explains itself where the mark is — the sign-off ledger keys its own pips, + * the CI / weight / age popovers carry their own rules, every group header + * glosses its word on hover, the hidden count names what it hides. What + * remains here is only what no single mark can teach: the board's invented + * vocabulary (stamp), its color grammar, the coordination signals, the query + * tokens, and the keyboard. One screen, no folds, no scrolling essays. */ export function Legend() { - const s = useSettings(); - return ( ( )} > - + Reading the board +

+ Everything explains itself where it sits: hover any mark, flag, header, or count. These + are the conventions that don’t announce themselves. +

- + stamp} - def="a CR or QA sign-off, left as a comment on the PR" + def="a CR or QA sign-off: a GitHub approval, or a “CR 👍” / “QA 👍” comment. A push undoes it until it’s re-confirmed" /> - CR + } - def="one mark per required stamp: a solid check is an approval that stands, an empty ring is still needed. Click a row’s marks for who signed" + def="one circle per required stamp, in order: stands, staled by a push, still needed. A dotted underline marks yours; click them for who signed. While a PR is a draft, blocked, or red, the re-ask waits: the author moves first" /> - - - - } - def="the outlined check is an approval a later push left stale: it stood once, and needs re-confirming against the new code" - /> - - + CI + + } - def="the dotted underline marks a slot you stamped" - /> - Re-stamp} - def="a push undid your stamp and the board is asking again — but only once the PR is reviewable: while it's a draft, dev-blocked, or CI is red, the next move is the author's and no re-stamp is asked of you yet" - /> - is testing it} - def="someone claimed QA by adding the QAing label on GitHub" + def="CI wears the same circles: the machine is a reviewer. Red ✗: a check failed. Sweeping ring: running, the filled share done. Passing shows only on row hover: no news is good news" /> - - - - - } - def={`age: a grey hairline along a row's bottom edge appears once it's ${s.ageWarnDays}+ days without full review; its length and depth are relative to the board's longest-open pull, so the oldest runs full width in full grey. Hover the line itself (it widens) or the quiet day count at the row's right edge for both clocks`} - /> + - XS - M - XL -
- } - def="review effort: the letter beside the CR marks, lightest to heaviest (a “?” means the wire sent no size). Hover for the word and the exact +/− lines, click to filter" - /> -
- - - How weight is decided - -
-

- XS through XL is roughly how much review a PR will take, lightest to heaviest. - The board sorts by it, and you can filter on it ( - weight:xs,s). -

-

- If the PR carries one of the org’s size labels, that wins: it’s deterministic, - per-file-weighted, and versioned with the labeller. With no label it’s a guess - from the diff: under 50 lines changed reads XS, under 150 S, under 600 M, under - 1500 L, and 1500+ XL, bumped up a class when the PR spans more than 15 files. -

-

- It’s only a prior, never a verdict: a tiny diff can hide a subtle change and a - big one can be a rename sweep, so the meter points you at what to read, it - doesn’t decide for you. -

-
-
- - CI - - + + Re-stamp · 3 } - def="CI wears the same marks as CR and QA. The machine is a reviewer too. A red ✗ disc means one or more checks failed (the popover says which); a gray-blue ring filling clockwise means the checks are still running, and the filled share is how many have finished. Passing shows nothing at rest. No news is good news; hover a row for its quiet green check and the per-check list" - /> - } - def="lines added and removed, neutral on purpose: a line count is a routine fact, not a verdict" + def="brand is your move: a brand header names an action you owe, a gray one says why a card waits. Hover the word for its meaning" /> stacked } - def="row flags: amber = act on it (conflicts, deploy block, external), gray = a neutral fact (stacked, CI, recent changes). Hover them for the full meaning" - /> - - - - } - def="matches a code region you set in Settings: a bare mark, hover it for which region(s). These gather in the “In your code regions” section on Review and Team, where the lane heading already says it, so the mark itself doesn’t render there" - /> -
- - - How regions work - -
-

- Code regions are free text you set in Settings, the areas you own or follow, - like “Growthbook” or “Shopify”. They’re a plain text match, not a regex. -

-

- A PR matches when a region appears, case-insensitively, anywhere in the text - already on the board: its title, description, repo, branch name, or labels. - Changed file paths aren’t checked (the board never fetches them). A match earns - the diamond mark, floats up your queue, and collects in “In your code regions”. -

-
-
- ✦} - def="review requested: GitHub asked you directly. These PRs land in Waiting on you, and the board quiets its own turn rotation for them" + def="amber flag: act on it. Gray flag: a plain fact" /> - ✋ + + } - def="claim a review so teammates know you’re on it; the hand stays lit on your row, and claimed PRs collect in “You’re reviewing”. It nudges you if it sits too long (set in Settings) and clears once you submit, release it, or GitHub removes you as a reviewer" + def="claim a review: adds you as a reviewer on the PR, so GitHub and the board both show you’re on it. Clears when you submit, release, or are removed" /> X is reading it} - def="someone else has claimed this review" + term={requested from you} + def="GitHub asked you directly. Lands in Waiting on you, and the board quiets its own suggestions for that PR" /> -
- - - How claims work - -
-

- Claiming a review adds you as a requested reviewer on the PR, through - pulldasher’s own bot account. It’s the same kind of GitHub review request as - anyone else asking you to review, so everyone sees it: on the board, as your - row’s raised hand, and on the PR itself on GitHub. -

-

- It clears the same way any GitHub review request does: you submit your review, - you release it from Pulldasher, or anyone removes you as a reviewer on GitHub. - There’s no timer counting it down. -

-

- Because GitHub holds the claim, not the server, it survives a server restart. - The one thing that doesn’t always survive is the “X ago” timestamp: right after - a restart the server hasn’t caught up on exactly when the request was made, so - a claim can briefly show with no age attached. -

-
-
your turn} - def="a starved review nobody’s on gets pointed at the best-matched person so it doesn’t sit forever; a toast asks them to claim it, and anyone can still take it" - /> -
- - - How your turn is picked - -
-

- When a PR has gone too long without enough CR and nobody has claimed it, the - board points it at one reviewer so it stops falling through the cracks. The - candidates are everyone who’s CR’d that repo before (minus the author and - anyone who already stamped this one), so they all know the code; the pick then - favors whoever the author has reviewed before, a good turn owed back, the same - signal the review queue’s ranking leans on. -

-

- If you’re the pick, a toast asks you to claim it right there (which also adds - you as a GitHub reviewer). Every client lands on the same name with no - coordination, and ties spread across PRs so it isn’t always one person. It’s a - nudge, not a lock; anyone can take it, and an explicit GitHub review request - overrides the guess entirely. -

-
-
-
- - - - Re-stamp · 3 - - } - def="brand group header: cards under it need an action from you; the word is the action. Hover any header for its one-line meaning" - /> - - waiting on CR · 2 - - } - def="gray group header: why those cards wait. Hover a card’s repo #number for the full state: status, names, dates, CI, feedback" - /> - - - - - - - - } - def="the filter bar's “hidden” count names what the board holds back — parked (Cryogenic Storage) PRs, others' drafts, muted repos and people — and shows any of it for the session" + def="a review that sat too long, pointed at the best-matched reviewer. A nudge, not a lock: anyone can take it" /> weight:xs,s} - def="review-effort class(es), comma list ORs; click a row’s weight meter to toggle it" + def="review-effort classes; a row’s weight letter filters with a click" /> has:action} - def="cards where you personally have a move to make" + def="cards where the next move is yours" /> is:restamp} - def="cards where a push owes you a re-CR or re-QA" + def="a push undid your stamp and the PR is reviewable again" /> is:blocked} - def="cards under a dev or deploy block" - /> - - diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index e0641208..3d31b29b 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -382,7 +382,7 @@ function RowActions({ title={ claim ? `claim review, currently ${claim.login}'s` - : "claim this review, flags that you're reading it" + : 'claim this review: adds you as a reviewer on the PR itself, so GitHub and the board both show you’re on it' } className={btn} onClick={a.claim} @@ -603,8 +603,12 @@ function WeightLetter({ pull, opts }: { pull: DerivedPull; opts: RowOptions }) { )} - - {sizeKnown ? 'from diff size' : 'size estimated'} + {/* how the letter is decided, in place of the old legend essay: the + popover is the drill-down, so the rule lives here */} + + {sizeKnown + ? 'From the org’s size label when the PR has one, else the diff: 50 / 150 / 600 / 1500 lines step XS through XL, one class up past 15 files. A pointer, not a verdict.' + : 'A guess: the wire sent no diff size.'} {onWeightToggle && ( diff --git a/frontend-v2/src/components/WordGroups.tsx b/frontend-v2/src/components/WordGroups.tsx index 45c4e7f4..8e8ca2a9 100644 --- a/frontend-v2/src/components/WordGroups.tsx +++ b/frontend-v2/src/components/WordGroups.tsx @@ -47,12 +47,15 @@ const WORD_GLOSS: Record = { 'with author': 'Changes were requested; the next push is the author’s.', 'waiting on re-QA': 'Someone tested it, then new commits landed. Waiting on them to test again.', 'waiting on QA': 'Waiting for someone to test it.', - 'in QA': 'Someone is testing it right now.', + 'in QA': + 'Someone is testing it right now: they added the QAing label on GitHub. Add it yourself to claim a QA.', claimed: 'Someone flagged they’re reading it.', stamped: 'Your stamp is in; waiting on the rest of the sign-offs.', 'CI running': 'Checks are still running.', - 'CI red': 'A required check is failing; the author fixes that first.', - blocked: 'The author paused it with a dev block.', + 'CI red': + 'A required check is failing; the author fixes that first. Stale stamps wait too: nobody is asked to re-stamp until it’s green.', + blocked: + 'Someone left a dev block; the author owes changes first. Stale stamps wait until the block lifts.', 'deploy hold': 'Done, but deliberately not shipped yet.', conflicts: 'Conflicts with its base branch; the author rebases.', stacked: 'Based on another open PR; it merges with its parent.', diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 5885e889..e9bcab87 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -1,5 +1,9 @@ import { useState } from 'react'; -import type { ButtonHTMLAttributes, CSSProperties, KeyboardEvent as ReactKeyboardEvent } from 'react'; +import type { + ButtonHTMLAttributes, + CSSProperties, + KeyboardEvent as ReactKeyboardEvent, +} from 'react'; import { Check, CircleDot, Star, X } from 'lucide-react'; import { type DerivedPull, @@ -319,7 +323,8 @@ export function CiStatus({ pull }: { pull: DerivedPull }) { : [...headStatuses(pull.data)].sort( (a, b) => ciRank(a) - ciRank(b) || a.data.context.localeCompare(b.data.context) ); - if (!checks.length) return ; + if (!checks.length) + return ; const failing = checks.filter(isRedCheck).length; const passing = checks.filter(c => c.data.state === 'success').length; @@ -373,7 +378,11 @@ export function CiStatus({ pull }: { pull: DerivedPull }) { ) : ( @@ -639,6 +648,13 @@ export function SigPips({ )} ))} + {/* the mark vocabulary, keyed right where the marks are read — the + legend stays a conventions card, not a per-mark decoder */} + + stands + staled by a push + needed +
); } From d78337d238bbe1ba90f8463f4b0b99df986ee5ad Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 13:35:11 -0700 Subject: [PATCH 268/381] Parked pulls ask nothing of anyone; a draft's move is Undraft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coworker report, reproduced on the live board: a PR that was both a draft and parked in Cryogenic Storage sat at the TOP of "Your move" under Re-stamp. The draft half was fixed by the authorOwnsIt gate; this closes the parked half and renames the draft verb. Parked (the Cryogenic Storage label) now masks the whole action machine, the author included — stronger than authorOwnsIt, where the author still has a move: parked means deliberately shelved, so nothing is owed by anyone until the label comes off. The new `parked` predicate gates rowNote (the card reads "parked, kept open on purpose" and groups under a new "parked" wait word with its own header gloss), authorMove and reviewerMove (no lane slots, no desktop nudges), the turn rotation (a starved parked pull no longer gets pointed at anyone), Review's offered-work pools (queue, bot queue, QA), the Team/People owes-re-stamps maps, and the is:restamp token. actionState follows rowNote, so the State filter agrees. This only ever mattered when a parked pull was revealed (the hidden ledger, showCryo, or show-all) — but revealed is exactly when the coworker hit it. "Finish the draft" becomes "Undraft": the exit verb, matching the Unblock naming pattern the owner asked for. The move on your own draft is marking it ready for review, not "finishing" it — the old word implied the board knew how done the code was. Still last in the do-word ranking and still excluded from desktop nudges and Review's home lane (planning, not minutes). Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/StatePopover.tsx | 2 +- frontend-v2/src/components/WordGroups.tsx | 4 ++- frontend-v2/src/model/actions.test.ts | 34 ++++++++++++++++++++- frontend-v2/src/model/actions.ts | 30 +++++++++++++----- frontend-v2/src/model/query.test.ts | 4 +++ frontend-v2/src/model/query.ts | 10 +++--- frontend-v2/src/model/rotation.ts | 4 ++- frontend-v2/src/views/People.tsx | 4 +-- frontend-v2/src/views/Review.tsx | 5 ++- frontend-v2/src/views/Team.tsx | 4 +-- 10 files changed, 81 insertions(+), 20 deletions(-) diff --git a/frontend-v2/src/components/StatePopover.tsx b/frontend-v2/src/components/StatePopover.tsx index 9454dad4..d9ed6273 100644 --- a/frontend-v2/src/components/StatePopover.tsx +++ b/frontend-v2/src/components/StatePopover.tsx @@ -68,7 +68,7 @@ function feedbackSources(p: DerivedPull): FeedbackSource[] { * four never set one, so the popover restates the action in plain words * instead of leaving Section 1 blank. */ const ACTION_EXPLANATION: Record = { - 'Finish the draft': 'still being drafted, not ready for review yet.', + Undraft: 'your draft; mark it ready on GitHub when you want review.', 'Merge it': 'fully signed off and green, go ahead and merge.', 'Finish QA': "you're already testing it, finish up and stamp.", 'Re-QA': 'a push invalidated your QA stamp.', diff --git a/frontend-v2/src/components/WordGroups.tsx b/frontend-v2/src/components/WordGroups.tsx index 8e8ca2a9..a8bdd5a4 100644 --- a/frontend-v2/src/components/WordGroups.tsx +++ b/frontend-v2/src/components/WordGroups.tsx @@ -39,7 +39,7 @@ const WORD_GLOSS: Record = { 'Find QA-er': 'CR isn’t the gate here, QA is. Line someone up to test it.', Review: 'Open PRs you could code review.', QA: 'Open PRs you could test.', - 'Finish draft': 'Your draft. Not up for review until you open it.', + Undraft: 'Your draft. Mark it ready on GitHub when you want review.', // wait-words: why the pull sits 'waiting on re-CR': 'A reviewer approved it, then new commits landed. Waiting on them to approve again.', @@ -60,6 +60,8 @@ const WORD_GLOSS: Record = { conflicts: 'Conflicts with its base branch; the author rebases.', stacked: 'Based on another open PR; it merges with its parent.', 'on hold': 'Blocked on something outside this repo.', + parked: + 'Labeled Cryogenic Storage: shelved on purpose. Nothing is asked of anyone while it’s parked.', ready: 'Fully signed off and green; waiting on the author to merge.', draft: 'Not up for review yet.', waiting: 'Waiting, and the board can’t say on what.', diff --git a/frontend-v2/src/model/actions.test.ts b/frontend-v2/src/model/actions.test.ts index 92c72a90..a7e58f74 100644 --- a/frontend-v2/src/model/actions.test.ts +++ b/frontend-v2/src/model/actions.test.ts @@ -29,6 +29,7 @@ function dp(o: { devBlockedBy?: string[]; deployBlockedBy?: string[]; externalBlock?: boolean; + cryo?: boolean; }): DerivedPull { return { data: { @@ -58,6 +59,7 @@ function dp(o: { devBlockedBy: o.devBlockedBy ?? [], deployBlockedBy: o.deployBlockedBy ?? [], externalBlock: o.externalBlock ?? false, + cryo: o.cryo ?? false, } as unknown as DerivedPull; } @@ -156,7 +158,8 @@ describe('rowNote — the author matrix', () => { const me = 'author'; it('draft', () => { - expect(note({ status: 'draft' }, me)).toEqual({ action: 'Finish the draft', context: null }); + // "Undraft" is the exit verb: the move is marking it ready for review + expect(note({ status: 'draft' }, me)).toEqual({ action: 'Undraft', context: null }); }); it('ci_red names the failing checks when known', () => { @@ -385,6 +388,35 @@ describe('rowNote — the author matrix', () => { }); }); +describe('parked (Cryogenic Storage) — asks nothing of anyone', () => { + it('masks every move, the author’s included', () => { + // a parked pull is shelved on purpose: no do-word for the author… + expect(rowNote(dp({ author: 'me', status: 'ready', cryo: true }), 'me')).toEqual({ + action: null, + context: 'parked, kept open on purpose', + }); + expect(authorMove(dp({ author: 'me', status: 'ci_red', cryo: true }))).toBeNull(); + // …no re-stamp or lane slot for a reviewer… + expect( + reviewerMove(dp({ author: 'a', status: 'needs_recr', recrBy: ['me'], cryo: true }), 'me') + ).toBeNull(); + // …no desktop nudge, and the State bucket is the wait it shows + expect( + alertMove(dp({ author: 'a', status: 'needs_qa', reqaBy: ['me'], cryo: true }), 'me') + ).toBeNull(); + expect(actionState(dp({ author: 'a', status: 'needs_cr', cryo: true }), 'me')).toBe( + 'waiting' + ); + }); + + it('groups under its own wait word', () => { + expect(rowWord(dp({ author: 'a', status: 'needs_cr', cryo: true }), 'me')).toEqual({ + kind: 'wait', + word: 'parked', + }); + }); +}); + describe('rowNote — the non-author matrix', () => { const me = 'me'; const author = 'auth'; diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index 1323cc86..36d6c8f5 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -17,6 +17,15 @@ import { isBotLogin } from './visibility'; export const authorOwnsIt = (p: DerivedPull): boolean => p.status === 'draft' || p.status === 'dev_block' || p.status === 'ci_red'; +/** + * A parked pull (the Cryogenic Storage label) asks nothing of ANYONE — + * author included — until the label comes off. Stronger than authorOwnsIt + * (where the author still has a move): parked means deliberately shelved, + * so no do-word, no lane slot, no turn, no nudge, whether or not the pull + * is visible on the board. + */ +export const parked = (p: DerivedPull): boolean => p.cryo; + /** * The verb column: what moves this pull, and whose move is it? Review's * "Yours to do" lane and My work's "Your move" both read from here so the @@ -26,6 +35,7 @@ export const authorOwnsIt = (p: DerivedPull): boolean => /** Your move on a pull you authored; null = waiting on someone else. Only ever * called for the viewer's own pulls, so the author IS the viewer here. */ export function authorMove(p: DerivedPull): string | null { + if (parked(p)) return null; if (p.status === 'ready') return 'Merge it'; if (p.status === 'ci_red') return 'Fix CI'; // a dev block is feedback waiting on YOU — v1 lore said "ask them to lift @@ -42,7 +52,8 @@ export function authorMove(p: DerivedPull): string | null { // there is nothing to rebase and no move to nudge about if (p.conflict) return 'Rebase'; if (p.status === 'needs_qa' && !p.qaingLogin && !p.reqaBy.length) return 'Find a QA-er'; - if (p.status === 'draft') return 'Finish the draft'; + // the exit verb, not "finish": the move is marking it ready for review + if (p.status === 'draft') return 'Undraft'; return null; } @@ -56,7 +67,7 @@ export function authorMove(p: DerivedPull): string | null { * reviewers for nothing until the author's move lands. */ export function reviewerMove(p: DerivedPull, me: string): string | null { if (p.data.user.login === me) return null; - if (authorOwnsIt(p)) return null; + if (parked(p) || authorOwnsIt(p)) return null; if (p.recrBy.includes(me)) return 'Re-stamp'; if (p.qaingLogin === me) return 'Finish QA'; if (p.reqaBy.includes(me)) return 'Re-QA'; @@ -75,8 +86,8 @@ export function alertMove(p: DerivedPull, me: string): string | null { const v = authorMove(p); // 'Lift your block' is a block you put on yourself — a standing choice, // not an event that landed on you, so it earns no desktop nudge (same as - // 'Find a QA-er' / 'Finish the draft') - const standing = v === 'Find a QA-er' || v === 'Finish the draft' || v === 'Lift your block'; + // 'Find a QA-er' / 'Undraft') + const standing = v === 'Find a QA-er' || v === 'Undraft' || v === 'Lift your block'; return v && !standing ? v : null; } const v = reviewerMove(p, me); @@ -125,7 +136,7 @@ function authorNote(p: DerivedPull, me: string): RowNote { const pushed = p.headPushedAt ? ` · last commit ${ago(p.headPushedAt)} ago` : ''; const crReq = d.status.cr_req; - if (p.status === 'draft') return doOnly('Finish the draft'); + if (p.status === 'draft') return doOnly('Undraft'); if (p.status === 'ci_red') return { action: 'Fix CI', context: p.ciFailing.length ? p.ciFailing.join(', ') : null }; // a dev block is feedback waiting on YOU — v1 lore said "ask them to lift @@ -434,6 +445,9 @@ export function rowNote( me: string, extra?: { claim?: Claim | null; turn?: string | null } ): RowNote { + // parked outranks everything, the author's own moves included: a shelved + // pull is a wait for everyone until the label comes off + if (parked(p)) return waitOnly('parked, kept open on purpose'); const note = p.data.user.login === me ? authorNote(p, me) : reviewerNote(p, me, extra); // An external blocker is worth surfacing over a generic wait, but never // hides an actual move — a real action always wins. On your OWN pull the @@ -470,7 +484,7 @@ const DO_WORD: Record = { 'Find a QA-er': 'Find QA-er', 'Review it': 'Review', 'QA it': 'QA', - 'Finish the draft': 'Finish draft', + Undraft: 'Undraft', }; /** Same freshness rule withCoordination uses for a claim by someone else — but @@ -491,6 +505,7 @@ function freshOtherClaim(me: string, claim?: Claim | null): boolean { * claim only absolves a viewer with no stake of their own in the pull. */ function waitWord(p: DerivedPull, me: string, extra?: { claim?: Claim | null }): string { + if (parked(p)) return 'parked'; if (p.externalBlock) return 'on hold'; const isAuthor = p.data.user.login === me; switch (p.status) { @@ -564,7 +579,7 @@ export const DO_WORD_RANK: readonly string[] = [ 'Find QA-er', 'Review', 'QA', - 'Finish draft', + 'Undraft', ]; /** Most-urgent-first order for the 'wait' word groups. */ @@ -584,6 +599,7 @@ export const WAIT_WORD_RANK: readonly string[] = [ 'conflicts', 'stacked', 'on hold', + 'parked', 'ready', 'draft', 'waiting', diff --git a/frontend-v2/src/model/query.test.ts b/frontend-v2/src/model/query.test.ts index d2c1d204..92c05754 100644 --- a/frontend-v2/src/model/query.test.ts +++ b/frontend-v2/src/model/query.test.ts @@ -124,6 +124,10 @@ describe('matchesQuery', () => { expect(matchesQuery(fake({ status: 'draft', reqaBy: ['bob'] }), 'is:restamp', 'bob')).toBe( false ); + // parked pulls ask nothing of anyone + expect( + matchesQuery({ ...fake({ recrBy: ['bob'] }), cryo: true } as never, 'is:restamp', 'bob') + ).toBe(false); }); it('is:blocked matches dev_block and deploy_block statuses only', () => { diff --git a/frontend-v2/src/model/query.ts b/frontend-v2/src/model/query.ts index 4560ee94..c1ea4bdb 100644 --- a/frontend-v2/src/model/query.ts +++ b/frontend-v2/src/model/query.ts @@ -1,4 +1,4 @@ -import { authorOwnsIt, rowNote } from './actions'; +import { authorOwnsIt, parked, rowNote } from './actions'; import type { DerivedPull } from './status'; /** @@ -43,10 +43,12 @@ function matchTerm(p: DerivedPull, term: string, me: string): boolean { return val.split(',').filter(Boolean).includes(p.weight.toLowerCase()); if (key === 'has') return val === 'action' && rowNote(p, me).action != null; if (key === 'is') { - // same authorOwnsIt gate as reviewerMove: no re-stamp is owed - // while the pull is a draft, dev-blocked, or red-CI + // same gates as reviewerMove: no re-stamp is owed while the pull + // is parked, a draft, dev-blocked, or red-CI if (val === 'restamp') - return !authorOwnsIt(p) && (p.recrBy.includes(me) || p.reqaBy.includes(me)); + return ( + !parked(p) && !authorOwnsIt(p) && (p.recrBy.includes(me) || p.reqaBy.includes(me)) + ); if (val === 'blocked') return p.status === 'dev_block' || p.status === 'deploy_block'; return false; } diff --git a/frontend-v2/src/model/rotation.ts b/frontend-v2/src/model/rotation.ts index 4ce56046..d21a4891 100644 --- a/frontend-v2/src/model/rotation.ts +++ b/frontend-v2/src/model/rotation.ts @@ -67,7 +67,9 @@ export function turnFor( pools: ReadonlyMap, pulls: readonly DerivedPull[] ): string | null { - if (!p.starved || !TURN_STATUSES.includes(p.status)) return null; + // a parked (Cryogenic Storage) pull asks nothing of anyone, so it never + // enters the rotation, starved or not + if (p.cryo || !p.starved || !TURN_STATUSES.includes(p.status)) return null; // An explicit GitHub review request answers "whose turn" authoritatively — // don't also rotate a name onto the pull, or the board would tell someone // it's their turn on a PR GitHub already routed to a specific reviewer. diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx index 3ce59ebd..147ecf04 100644 --- a/frontend-v2/src/views/People.tsx +++ b/frontend-v2/src/views/People.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { authorOwnsIt } from '../model/actions'; +import { authorOwnsIt, parked } from '../model/actions'; import { STATUS_ORDER, type DerivedPull } from '../model/status'; import { useSettings } from '../settings'; import type { Team } from '../types'; @@ -42,7 +42,7 @@ export function People({ for (const p of allPulls) counts.set(p.data.user.login, (counts.get(p.data.user.login) ?? 0) + 1); const owes = new Map(); - for (const p of allPulls.filter(x => !authorOwnsIt(x))) for (const u of p.recrBy) owes.set(u, [...(owes.get(u) ?? []), p]); + for (const p of allPulls.filter(x => !parked(x) && !authorOwnsIt(x))) for (const u of p.recrBy) owes.set(u, [...(owes.get(u) ?? []), p]); // starred people lead the directory; muted ones drop out entirely unless // they're the person an explicit pick (a URL or a click) already landed on diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 80b2c8db..447a6d80 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -94,6 +94,7 @@ export function Review({ // stale is the re-review gap that put already-done work back in your queue. const crPool = others.filter( p => + !p.cryo && !p.crBy.includes(me) && (p.status === 'needs_cr' || (p.status === 'needs_recr' && !p.recrBy.includes(me))) ); @@ -109,6 +110,7 @@ export function Review({ const botReviewable = bots .filter( p => + !p.cryo && !p.crBy.includes(me) && (p.status === 'needs_cr' || (p.status === 'needs_recr' && !p.recrBy.includes(me))) ) @@ -128,6 +130,7 @@ export function Review({ // read both pools before either lane's pool is filtered) const qaPool = others.filter( p => + !p.cryo && !qaDone(p) && ['success', 'none'].includes(p.ci) && !p.conflict && @@ -612,7 +615,7 @@ export function Review({ } // Your own pulls contribute only their do-it-now verbs to the home lane. -// "Finish the draft" always stays in My work (planning, not minutes). Getting +// "Undraft" always stays in My work (planning, not minutes). Getting // QA is different: when the team self-reviews, CR isn't the gate and lining up // QA is the daily stall, so "Find a QA-er" graduates to a home to-do. function ownVerb(p: DerivedPull, selfReview: boolean): string | null { diff --git a/frontend-v2/src/views/Team.tsx b/frontend-v2/src/views/Team.tsx index 00776e3b..b6e81cfe 100644 --- a/frontend-v2/src/views/Team.tsx +++ b/frontend-v2/src/views/Team.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { authorOwnsIt } from '../model/actions'; +import { authorOwnsIt, parked } from '../model/actions'; import type { DerivedPull } from '../model/status'; import { matchesRegion } from '../model/regions'; import { crSort } from '../model/sort'; @@ -47,7 +47,7 @@ export function Team({ }, [allPulls]); const owes = useMemo(() => { const m = new Map(); - for (const p of allPulls.filter(x => !authorOwnsIt(x))) for (const u of p.recrBy) m.set(u, [...(m.get(u) ?? []), p]); + for (const p of allPulls.filter(x => !parked(x) && !authorOwnsIt(x))) for (const u of p.recrBy) m.set(u, [...(m.get(u) ?? []), p]); return m; }, [allPulls]); From 202e12e411362722460aa7d6ea5fa8592c7b20e7 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 13:35:11 -0700 Subject: [PATCH 269/381] =?UTF-8?q?Retire=20the=20CI=20=E2=9C=97=20disc:?= =?UTF-8?q?=20one=20glyphless=20circle,=20animated=20with=20purpose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The knocked-out ✗ made CI's failure the loudest glyph on the rail — louder than any human mark (owner call: drop it entirely). CI is now a glyphless circle, the machine's mark; check glyphs are reserved for human stamps, so the rail reads people-with-checks beside machine-with-a-circle. Three standings, one 14px geometry (.ci-ring / .ci-disc): - Failing: a solid red disc. Mass and color carry the alarm, no glyph. It lands with a single 320ms scale-settle and then holds still — an alarm that keeps moving is a nag, not a mark. - Running: the slate ring sweeping clockwise, the filled share being checks done. It breathes on a 2.4s cycle so a live run reads as alive, and --sweep is a registered @property (top-level, layers don't own it) so the share GLIDES between values instead of snapping when another check finishes. - Passed: a green ring that draws itself closed on row hover or focus. Invisible at rest (no news is good news), and the reveal IS the animation. Replaces the green ✓ disc, which borrowed the human mark. prefers-reduced-motion disables the breathe, the land, and the draw (the pass ring simply appears closed). The dead .pip-fail, .pip-run, and .pip-progress classes are removed; the Legend's CI line and DESIGN.md's rail taxonomy now describe the circle language. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 2 +- frontend-v2/src/components/Legend.tsx | 9 ++- frontend-v2/src/components/bits.tsx | 21 +++--- frontend-v2/src/styles.css | 101 ++++++++++++++++++++------ 4 files changed, 97 insertions(+), 36 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 8f7d7a4a..e9dde701 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -115,7 +115,7 @@ member of the sign-off family (it did, when weight was a dot): | Shape | Metric | Behavior | |---|---|---| -| circle marks (14px SVG masks, `.pip`) | CI/CR/QA sign-off | ONE family for all three reviewers — the machine is a reviewer too. The label names the reviewer, the glyph names the verdict (✓ approved, ✗ failed), the treatment names the standing (solid stands, outlined lapsed). CI is ONE circle in three standings: a red ✗ disc = failing (always binary — a 1-of-25 failure must read as loudly as 25-of-25, so the fraction lives in the popover, never the mark); a slate progress ring sweeping clockwise = running, the sweep being the completed share of checks (continuous, so 3 checks and 25 read the same; the successor to v1's grey running section); a green ✓ disc = passed, revealed on row hover only. All 14px. **The 18px alarm tier is retired**: it earned its size when the rail was crowded with the weight ruler; in the quiet single-line rail, red-as-the-only-filled-disc is already the loudest mark, and the extra size plus red count read as shouting (owner call). In wrapped narrow rails (≤520px containers) the quiet/no-check CI slot collapses instead of reserving a hole — cross-row alignment only exists in wide lanes | +| circle marks (14px SVG masks, `.pip` for humans; `.ci-ring`/`.ci-disc` for CI) | CI/CR/QA sign-off | ONE geometry for all three reviewers, but **check glyphs are reserved for humans**: CR/QA pips carry the ✓ (solid stands, outlined lapsed, empty needed); CI is a glyphless circle — the machine's mark (2026-07: the knocked-out ✗ disc was retired as louder than any human mark; mass and color carry the alarm now). CI's three standings, all animated with purpose: a **solid red disc** = failing (always binary — a 1-of-25 failure must read as loudly as 25-of-25, so the fraction lives in the popover, never the mark; it lands once with a 320ms scale-settle, then holds still — an alarm that keeps moving is a nag); a **slate ring sweeping clockwise** = running, the sweep being the completed share of checks (breathing at 2.4s so a live run reads as alive; `--sweep` is a registered `@property` so share changes glide); a **green ring that draws itself closed** on row hover = passed (invisible at rest — no news is good news — and the reveal is the animation). Reduced motion: no breathe, no land, pass-ring appears already closed. All 14px. **The 18px alarm tier is retired**: it earned its size when the rail was crowded with the weight ruler; in the quiet single-line rail, red-as-the-only-solid-disc is already the loudest mark. In wrapped narrow rails (≤520px containers) the quiet/no-check CI slot collapses instead of reserving a hole — cross-row alignment only exists in wide lanes | | quiet text letter (11px, same fixed slot as the CI/CR/QA label) | review weight | XS/S/M/L/XL, or "?" when the wire sent no size — not its own shape at all, deliberately: it rides inside the CR cluster (`CR` label · weight letter · pips) instead of inventing a fourth family or drawing a ratio strip. A prior 4px fill-doubles-per-class strip under the whole sign-off row was retired (2026-07): the owner found it visually loud and it only ever said what a letter already says. Same popover survives the move — the effort word, the exact +/− diff, and the "Filter to X PRs" action | | the row's baseline (1px hairline, bottom edge) | age / starvation | **gated, not always-on**: nothing below the aging threshold, then a grey hairline — a deepened stretch of the divider the row already has, never a drawn bar. **Relative, not thresholded**: the board's longest-open pull sets the full track and the deepest tint (ultra-light grey → the full border color, deliberately no darker than the row dividers it extends); every other row is a fraction of the oldest. **Grey only** — the amber version was disruptive and a red plateau before it read as "broken" everywhere; age is a quiet fact, urgency lives in the queue's ranking and the numeral's font weight. The quiet day count floats right in the meta line, capping the track. **The line is also a door** (2026-07): a taller invisible hit strip (10px, sized to the line's own drawn fraction, not the full row) makes a 1px target hoverable without pixel-hunting; hover or focus grows the line to an 8px band and opens the same age popover the numeral shows (opened X ago, last activity Y ago, the relative-to-the-oldest line) — one popover body, two doors, so they can't drift apart | diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index b5f5661c..bf71515b 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -94,11 +94,14 @@ export function Legend() { term={ CI - - + + } - def="CI wears the same circles: the machine is a reviewer. Red ✗: a check failed. Sweeping ring: running, the filled share done. Passing shows only on row hover: no news is good news" + def="CI is one circle, the machine’s mark: solid red, a check failed; the ring sweeping shut is a run in progress, the filled share done. On row hover a green ring closes the loop: passed. No news is good news" /> diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index e9bcab87..4385589f 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -364,20 +364,19 @@ export function CiStatus({ pull }: { pull: DerivedPull }) { CI - {/* one circle, three standings: a red ✗ disc = failing (the - fraction lives in the popover; a 1-of-25 failure must read - as loudly as 25-of-25), a slate ring sweeping closed = - running (the sweep is the completed share, v1's grey - section reborn), a green ✓ disc = passed, revealed on row - hover only. Same 14px as every other pip: with the weight - ruler gone the rail is quiet enough that red needs no size - escalation to be its loudest mark. */} + {/* one circle, no glyphs (styles.css .ci-ring/.ci-disc): a + solid red disc = failing (the fraction lives in the + popover; a 1-of-25 failure must read as loudly as + 25-of-25), the slate ring sweeping closed = running (the + sweep is the completed share, v1's grey section reborn), + and a green ring drawing itself shut on row hover = + passed. Check glyphs stay reserved for human stamps. */} {failing > 0 ? ( - + ) : pending ? ( ) : ( - + )} )} diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 8a1efc67..af4fc257 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -1,5 +1,14 @@ @import "tailwindcss"; +/* --sweep must be a registered for the CI ring's share to GLIDE + between values (an unregistered custom property snaps). Top level on + purpose: @property doesn't belong to a layer. */ +@property --sweep { + syntax: ""; + inherits: false; + initial-value: 0deg; +} + /* ── Design tokens: gray-ramp neutrals, one brand blue as the only action color, status badges as 200-tint bg / 900-step text. The dark board keeps pulldasher's night-shift habit, derived from the same gray ramp. Retheme @@ -216,32 +225,82 @@ label:has(> input:not(:disabled)) { --pip-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.6' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E"); background-color: var(--ink-3); } - /* CI's marks, same family: the machine is a reviewer too. A solid disc - with a knocked-out X = checks failing (red's ONLY meaning on the board); - the "still running" ring reuses the needed-ring shape in the pending - slate. Passing renders pip-on, revealed only on row hover. */ - .pip-fail { - --pip-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cdefs%3E%3Cmask id='x'%3E%3Crect width='16' height='16' fill='white'/%3E%3Cpath d='M5.4 5.4l5.2 5.2M10.6 5.4l-5.2 5.2' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'/%3E%3C/mask%3E%3C/defs%3E%3Ccircle cx='8' cy='8' r='7.4' fill='white' mask='url(%23x)'/%3E%3C/svg%3E"); - background-color: var(--bad); - } - .pip-run { - --pip-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.6' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E"); - background-color: var(--slate); - } - - /* CI while running: the run ring sweeping shut. The ring mask above cuts - the circle; a conic gradient paints the completed share of checks in - slate and leaves the rest as the secondary track, so 3 checks and 25 - checks both read as one quiet dial filling clockwise (v1's grey - "running" section, in the pip family's geometry). --sweep is set inline - by CiStatus. */ - .pip-progress { + /* ── CI is ONE circle, no glyphs: the machine's mark beside the human + checks (the old knocked-out ✗ read louder than any human mark; mass and + color carry the alarm now, and check glyphs stay reserved for people). + Three standings, one geometry: + running .ci-ring.ci-ring-run slate ring sweeping shut — the filled + share is checks done (--sweep, inline + from CiStatus), breathing slowly so a + live run reads as alive; the sweep + GLIDES when the share changes (the + registered --sweep property below) + failing .ci-disc the same circle as a solid red disc — + the run closed on a failure. Lands + once with a small settle, then holds + still: an alarm that keeps moving is + a nag, not a mark + passed .ci-ring.ci-ring-pass a green ring that draws itself closed + on row hover / focus — invisible at + rest (no news is good news), and the + reveal is the animation */ + .ci-ring { width: 14px; height: 14px; flex: none; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.6' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E") center / 100% 100% no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.6' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E") center / 100% 100% no-repeat; - background: conic-gradient(var(--slate) var(--sweep, 0deg), var(--secondary) var(--sweep, 0deg)); + background: conic-gradient(var(--ci-ink, var(--slate)) var(--sweep, 0deg), var(--ci-track, var(--secondary)) var(--sweep, 0deg)); + transition: --sweep 480ms var(--ease-quint); + } + .ci-ring-run { + animation: ci-breathe 2.4s ease-in-out infinite; + } + .ci-ring-pass { + --ci-ink: var(--ok); + --ci-track: transparent; + --sweep: 0deg; + } + .pd-row:hover .ci-ring-pass, + .pd-ci-slot:hover .ci-ring-pass, + .pd-ci-slot:focus-visible .ci-ring-pass { + --sweep: 360deg; + } + .ci-disc { + width: 14px; + height: 14px; + flex: none; + border-radius: 9999px; + background: var(--bad); + animation: ci-land 320ms var(--ease-quint); + } + @keyframes ci-breathe { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.55; + } + } + @keyframes ci-land { + from { + transform: scale(0.4); + opacity: 0; + } + } + @media (prefers-reduced-motion: reduce) { + .ci-ring { + transition: none; + } + .ci-ring-run, + .ci-disc { + animation: none; + } + /* no draw-on-hover either: the pass ring simply appears closed */ + .ci-ring-pass { + --sweep: 360deg; + } } /* the slot you personally stamped: a dotted rule under the marks */ From 4ac3bf3ffdcfa4b7788db2be14ad71bfd64aa569 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 13:51:47 -0700 Subject: [PATCH 270/381] Snooze goes Review-only; the rail reorders around its optional marks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four interlocking changes from one design pass, touching the same rows. Snooze is Review's own gesture. It used to hide a pull from EVERY lens via the shared visibility pipeline, with its only ledger a count buried in Settings. Now the Review view applies it itself: snoozed rows drop out of Review's lanes only, and collect in a visible "snoozed by you" fold at the bottom of The rest of the board, with the wake-all beside them — hiding is trustworthy when you can always see what's hidden. The Settings block and the hidden-ledger's snoozed row are gone (the board section replaces both), and the snooze button says what it now does: off your Review lens. The age numeral moves to the rail's far right and whispers. It was a heavy black column in the meta line; now it caps the rail — the row's true right edge, beside the marks it ranks with — wearing the age line's own border tint at rest and rising to ink on row hover. The warn/rot font weight survives for the hover read. A new setting picks which clock it shows (days since opened, or since last update); the urgency weight always follows the opened clock, and both clocks stay in the popover. The rail reorders: CR · QA · CI · age. CI is the rail's only optionally-rendered mark (green shows on hover only), so it moves to the end where its appearance can't shift the human marks — and Classic's narrow columns stop hiding it outright: the quiet slot collapses at rest and returns on row hover, so green is one hover away everywhere. The CR cluster becomes ONE door: the label, weight letter, and pips are a single trigger, and the weight drill-down (effort word, exact diff, the sizing rule, the filter action) is now a section of the CR ledger panel — the standalone weight popover is deleted. QA's label joins its trigger the same way, and a no-stamps cluster still opens its panel ("No stamps yet." plus the key) instead of being a dead spot. Parked/draft audit fixes: `starved` is now false for parked pulls at the source (status.ts) — they age on purpose, so starve nags, starve scores, and the turn rotation all stop seeing them as rotting; and the cheer/nag system sits parked pulls out entirely (author edge toasts, the board-cleared count, and the review-requested toast all gate on cryo). Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 33 +--- frontend-v2/src/components/Row.tsx | 172 ++++++++---------- frontend-v2/src/components/Settings.tsx | 37 ++-- frontend-v2/src/components/bits.tsx | 52 ++++-- .../src/components/filters/HiddenPanel.tsx | 11 +- frontend-v2/src/model/cheers.ts | 10 +- frontend-v2/src/model/status.test.ts | 19 ++ frontend-v2/src/model/status.ts | 9 +- frontend-v2/src/settings.ts | 6 + frontend-v2/src/styles.css | 30 ++- frontend-v2/src/views/Review.tsx | 39 +++- 11 files changed, 242 insertions(+), 176 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index a1b4cafb..25cd3c07 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -18,7 +18,7 @@ import { buildReviewerPools, turnFor } from './model/rotation'; import { shipRelevance, shippedToast } from './model/shipped'; import type { Toast } from './model/toast'; import type { Team as TeamGroup } from './types'; -import { claimReview, isSnoozed, markAllSeen, setWeightLabels, usePulldasher } from './store'; +import { claimReview, markAllSeen, setWeightLabels, usePulldasher } from './store'; import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy'; import { loadSiteConfig, primeScope, useScope } from './prefs'; import { getSettings, useSettings } from './settings'; @@ -228,7 +228,6 @@ export function App() { lastPayloadAt, lastSeen, acked, - snoozed, refreshProgress, } = usePulldasher(); // desktop notifications watch the whole board, not the current filter @@ -496,11 +495,10 @@ export function App() { p.data.user.login !== me && !revealedAuthor(p.data.user.login) && !reviewRequestedFrom(p, me); - // a snoozed pull stays off the board until tomorrow or its next - // change; the master reveal shows it like every other hidden group - return ( - hiddenRepo || hiddenPerson || cryoHidden || draftHidden || isSnoozed(p.data, snoozed) - ); + // snoozes are NOT here: a snooze only quiets the Review lens (its + // "not today" gesture), so the Review view applies it itself and + // every other lens still shows the pull + return hiddenRepo || hiddenPerson || cryoHidden || draftHidden; }, [ showAll, @@ -515,7 +513,6 @@ export function App() { settings.showCryo, settings.mutedPeople, draftsMode, - snoozed, ] ); @@ -608,7 +605,7 @@ export function App() { // rule covers, whether or not a session reveal currently shows it) plus // the live currently-hidden total for the trigger label const hiddenCounts = useMemo(() => { - const c = { parked: 0, drafts: 0, mutedRepos: 0, mutedPeople: 0, snoozed: 0, hiddenNow: 0 }; + const c = { parked: 0, drafts: 0, mutedRepos: 0, mutedPeople: 0, hiddenNow: 0 }; for (const p of pulls) { if (p.cryo) c.parked++; if (p.data.draft && p.data.user.login !== me && !reviewRequestedFrom(p, me)) c.drafts++; @@ -619,22 +616,10 @@ export function App() { personHidden(p.data.user.login, settings.mutedPeople) ) c.mutedPeople++; - if (isSnoozed(p.data, snoozed)) c.snoozed++; if (boardHidden(p)) c.hiddenNow++; } return c; - }, [ - pulls, - me, - hiddenRepos, - settings.repoPrefs, - settings.mutedPeople, - isBot, - snoozed, - boardHidden, - ]); - // counts every open pull a snooze currently hides, for the Settings surface - const snoozedCount = hiddenCounts.snoozed; + }, [pulls, me, hiddenRepos, settings.repoPrefs, settings.mutedPeople, isBot, boardHidden]); const isScoped = scope.repos.length || scope.authors.length || query; @@ -730,6 +715,7 @@ export function App() { maxAgeDays, ageWarnDays: settings.ageWarnDays, ageRotDays: Math.round(settings.ageWarnDays * 2.5), + ageDisplay: settings.ageDisplay, compact: settings.density === 'compact', laneCap: settings.laneCapByLens[lens] ?? settings.laneCap, parentOf, @@ -744,6 +730,7 @@ export function App() { onWeightToggle, maxAgeDays, settings.ageWarnDays, + settings.ageDisplay, settings.density, settings.laneCap, settings.laneCapByLens, @@ -933,7 +920,7 @@ export function App() { - setLens('team')} /> + setLens('team')} />
{/* min-w-0 + overflow-x-auto (no-scrollbar in styles.css) diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 3d31b29b..199b1edf 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -54,6 +54,8 @@ export interface RowOptions { /** age thresholds from user settings (fall back to the model's) */ ageWarnDays?: number; ageRotDays?: number; + /** which clock the age numeral shows (settings.ageDisplay) */ + ageDisplay?: 'opened' | 'updated'; /** the board's longest-open pull, in days — the age baseline's full * track; every row's line is a fraction of the oldest */ maxAgeDays?: number; @@ -350,8 +352,8 @@ function RowActions({ - )} - > - review effort: {word} +
+ + review effort:{' '} + {WEIGHT_WORD[weight]} + {sizeKnown ? '' : ' (estimated)'} + {sizeKnown && ( )} - {/* how the letter is decided, in place of the old legend essay: the - popover is the drill-down, so the rule lives here */} {sizeKnown ? 'From the org’s size label when the PR has one, else the diff: 50 / 150 / 600 / 1500 lines step XS through XL, one class up past 15 files. A pointer, not a verdict.' : 'A guess: the wire sent no diff size.'} - {onWeightToggle && ( + {opts.onWeightToggle && ( - onWeightToggle(weightFilterKey(pull))}> + opts.onWeightToggle?.(weightFilterKey(pull))}> Filter to {sizeKnown ? weight : 'unknown-size'} PRs )} - +
); } @@ -650,41 +618,63 @@ function MetricRail({ > - {/* one instrument: the three reviewers' marks in a row — machine - first, then the humans, weight riding along inside the CR - cluster. CI is invisible at rest unless failing (revealed on row - hover), in a reserved slot so nothing shifts. */} + {/* one instrument, humans first: CR (label, weight letter, pips — + one door into one panel), then QA, then the machine's circle at + the rail's end, where its optional render (green only on hover) + can't shift the human marks, and last the age numeral capping + the row. */} + + CR + {/* the owner's sketch was "CR · S ✓✓": the middot keeps the + label and the size letter from fusing at 11px */} + + · + + + {pull.sizeKnown ? pull.weight : '?'} + + + } + panelExtra={} + /> + QA} + /> - - CR - {/* the owner's sketch was "CR · S ✓✓": the middot keeps the - label and the size letter from fusing into one token at 11px */} - - · - - - - - - QA - - + {/* the age numeral caps the rail — the row's far-right column, a + whisper in the age line's own tint until the row is hovered + (styles.css .pd-age-num). Which clock it shows is the user's + call (settings.ageDisplay); both clocks stay in its popover. */} + ); } @@ -804,19 +794,6 @@ function RowImpl({ )} - {/* the age numeral floats right, capping the baseline track — - a quiet timestamp column, mail-client style */} - - - } rail={} @@ -856,6 +833,7 @@ export const Row = memo( a.opts.ageWarnDays === b.opts.ageWarnDays && a.opts.maxAgeDays === b.opts.maxAgeDays && a.opts.ageRotDays === b.opts.ageRotDays && + a.opts.ageDisplay === b.opts.ageDisplay && a.opts.onWeightToggle === b.opts.onWeightToggle && a.opts.parentOf === b.opts.parentOf && a.opts.pools === b.opts.pools && diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 494673a2..a84bc5d2 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Settings as SettingsIcon, X } from 'lucide-react'; import { clearStoredPrefs } from '../storage'; -import { clearSnoozes, markAllSeen, refreshAll, usePulldasher } from '../store'; +import { markAllSeen, refreshAll, usePulldasher } from '../store'; import { addCodeRegion, removeCodeRegion, @@ -121,11 +121,8 @@ function CodeRegionsGroup() { } export function Settings({ - snoozedCount, onGoToTeam, }: { - /** pulls currently hidden by a row snooze */ - snoozedCount: number; /** switch the board to the Team lens and close this panel — the "Edited * on the board" pointer line's action. The team picker and repo manager * embeds are gone (Team view and the header's Repos filter own those @@ -317,6 +314,20 @@ export function Settings({ onChange={ageWarnDays => set({ ageWarnDays })} /> + + set({ ageDisplay })} + /> +
- - A snoozed row hides for a day, or until the PR changes. - -
- clearSnoozes()} - > - Bring back snoozed - - - {snoozedCount} hidden now - -
- + {/* snoozes live on the board now: the Review lens + shows its own Snoozed section with the wake-all */}
ciRank(a) - ciRank(b) || a.data.context.localeCompare(b.data.context) ); if (!checks.length) - return ; + return ; const failing = checks.filter(isRedCheck).length; const passing = checks.filter(c => c.data.state === 'success').length; @@ -355,7 +356,7 @@ export function CiStatus({ pull }: { pull: DerivedPull }) { // One fixed slot width in every state (sized to the failing // cluster, the widest), so the rail — and the age numeral's // right edge in the meta line — stays one column down a lane. - className={`pd-ci-slot pressable -my-2 mr-1.5 inline-flex w-[36px] items-center gap-1 rounded border-0 bg-transparent px-0 py-2 hover:bg-secondary/60 ${ + className={`pd-ci-slot pressable -my-2 inline-flex w-[36px] items-center gap-1 rounded border-0 bg-transparent px-0 py-2 hover:bg-secondary/60 ${ failing > 0 || pending ? '' : 'pd-ci-quiet opacity-0 transition-opacity duration-150 hover:opacity-100 focus-visible:opacity-100 [.pd-row:hover_&]:opacity-100 motion-reduce:transition-none' @@ -563,6 +564,8 @@ export function SigPips({ staleBy = [], me, sigs, + lead, + panelExtra, }: { label: string; have: number; @@ -571,11 +574,13 @@ export function SigPips({ staleBy?: string[]; me?: string; sigs: Signature[]; + /** rendered inside the trigger before the pips — the cluster's text label + * (and, for CR, the weight letter), so the words are part of the door */ + lead?: ReactNode; + /** an extra panel section under the signer rows (CR carries the weight + * drill-down here — one popover for the whole cluster) */ + panelExtra?: ReactNode; }) { - // bare pips (the no-signatures fallback) keep their native title; inside - // the hover popover below it would just stack a browser tooltip on top - if (!sigs.length) - return ; const pips = ( ); @@ -606,11 +611,13 @@ export function SigPips({ )} @@ -625,6 +632,9 @@ export function SigPips({ )} + {rows.length === 0 && ( + No stamps yet. + )} {rows.map(s => ( staled by a push needed + {panelExtra} ); } @@ -784,10 +795,17 @@ export function AgeBaseline({ * The age slot: hours under a day, then days, with both clocks in the * popover. Hours matter here: in three months of real history, 62% of * pulls merged same-day, so "0d" was a dead signal for most of the live - * board. The urgency *color* lives on the row's AgeBaseline now — this - * numeral stays neutral ink and only gains font weight past the same - * thresholds, so age's salience is carried once, by the mark built for - * continuous gradation, not twice. + * board. It rides at the rail's far right, capping the row — and it + * WHISPERS: its resting color is the age line's own border tint, rising to + * ink on row hover (see styles.css .pd-age-num). Age's salience is carried + * once, by the baseline built for continuous gradation; the numeral is the + * label you consult, not a second alarm. Font weight still steps at the + * warn/rot thresholds so the hover read carries the urgency. + * + * `display` picks the clock the numeral shows (opened vs last update); the + * urgency weight always follows the OPENED clock — how long a pull has been + * open is the truth the board ranks by, whichever number the user prefers + * to read. */ export function AgeStamp({ ageDays, @@ -797,6 +815,7 @@ export function AgeStamp({ warnDays = STARVE_DAYS, rotDays = ROT_DAYS, inline, + display = 'opened', }: { ageDays: number; /** epoch secs the pull opened */ @@ -813,6 +832,8 @@ export function AgeStamp({ * fixed-width column — drops the w-7/text-right slot in favor of plain * inline text. */ inline?: boolean; + /** which clock the numeral shows (settings.ageDisplay) */ + display?: 'opened' | 'updated'; }) { const heft = quiet ? '' @@ -821,7 +842,12 @@ export function AgeStamp({ : ageDays >= warnDays ? 'font-medium' : ''; - const text = ageDays === 0 ? ago(createdAt) : `${ageDays}d`; + const shownEpoch = display === 'updated' ? updatedAt : createdAt; + const shownDays = + display === 'updated' + ? Math.max(0, Math.floor((Date.now() / 1000 - updatedAt) / 86400)) + : ageDays; + const text = shownDays === 0 ? ago(shownEpoch) : `${shownDays}d`; // a popover, not a title: the second clock (last activity) exists nowhere // else on the row, and a native tooltip is mouse-only — this way touch // taps it and keyboard reads it from the aria-label @@ -842,7 +868,7 @@ export function AgeStamp({ > {text} diff --git a/frontend-v2/src/components/filters/HiddenPanel.tsx b/frontend-v2/src/components/filters/HiddenPanel.tsx index 0687f9dc..bc26a9fc 100644 --- a/frontend-v2/src/components/filters/HiddenPanel.tsx +++ b/frontend-v2/src/components/filters/HiddenPanel.tsx @@ -16,8 +16,6 @@ export interface HiddenCounts { mutedRepos: number; /** PRs by people you muted */ mutedPeople: number; - /** PRs you snoozed */ - snoozed: number; /** PRs currently off the board, after session reveals */ hiddenNow: number; } @@ -56,7 +54,7 @@ export function HiddenPanel({ // the undo is one click, mirroring how an active filter clears const revealing = showAll || reveal.length > 0 || draftsMode !== settings.draftsMode; const anythingHidden = - counts.parked + counts.drafts + counts.mutedRepos + counts.mutedPeople + counts.snoozed > 0; + counts.parked + counts.drafts + counts.mutedRepos + counts.mutedPeople > 0; if (!anythingHidden && !revealing) return null; const resetReveals = () => { @@ -174,13 +172,6 @@ export function HiddenPanel({ counts.mutedPeople, 'Unmute them under People.' )} - {counts.snoozed > 0 && - infoRow( - 'snoozed', - 'Snoozed by you', - counts.snoozed, - 'Back tomorrow, or as soon as they change.' - )}
- review effort:{' '} - {WEIGHT_WORD[weight]} - {sizeKnown ? '' : ' (estimated)'} + review effort: {WEIGHT_WORD[weight]} + + + - {sizeKnown && ( - - - - )} - {sizeKnown - ? 'From the org’s size label when the PR has one, else the diff: 50 / 150 / 600 / 1500 lines step XS through XL, one class up past 15 files. A pointer, not a verdict.' - : 'A guess: the wire sent no diff size.'} + From the org’s size label when the PR has one, else the diff: 50 / 150 / 600 / 1500 + lines step XS through XL, one class up past 15 files. A pointer, not a verdict. {opts.onWeightToggle && ( opts.onWeightToggle?.(weightFilterKey(pull))}> - Filter to {sizeKnown ? weight : 'unknown-size'} PRs + Filter to {weight} PRs )} @@ -642,11 +637,9 @@ function MetricRail({ - {pull.sizeKnown ? pull.weight : '?'} + {pull.weight} } diff --git a/frontend-v2/src/components/filters/WeightFilter.tsx b/frontend-v2/src/components/filters/WeightFilter.tsx index 8662f751..876d97d1 100644 --- a/frontend-v2/src/components/filters/WeightFilter.tsx +++ b/frontend-v2/src/components/filters/WeightFilter.tsx @@ -3,15 +3,14 @@ import { weightFilterKey } from '../../model/status'; import { Popover } from '../Popover'; import { FilterRow, FilterTrigger, OnlyButton } from './shared'; -/** Weight filter option order: lightest to heaviest, then the unknown-size - * catch-all — mirrors the rail's own weight-letter read (XS through XL). */ +/** Weight filter option order: lightest to heaviest — mirrors the rail's + * own weight-letter read (XS through XL). */ const WEIGHT_OPTIONS: { key: string; label: string }[] = [ { key: 'xs', label: 'XS · very light' }, { key: 's', label: 'S · light' }, { key: 'm', label: 'M · medium' }, { key: 'l', label: 'L · heavy' }, { key: 'xl', label: 'XL · very heavy' }, - { key: 'unknown', label: 'Size unknown' }, ]; /** @@ -42,9 +41,7 @@ export function WeightFilter({ }; // the rail spells weights as letters (XS…XL); the trigger matches it - const value = weightSel.length - ? weightSel.map(k => (k === 'unknown' ? 'unknown' : k.toUpperCase())).join(', ') - : null; + const value = weightSel.length ? weightSel.map(k => k.toUpperCase()).join(', ') : null; return (
diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index 0f20ecd8..182c6ca9 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -279,7 +279,7 @@ export function startHereReason( const author = p.data.user.login; const owedByAuthor = pulls.some(o => o.data.user.login === me && hasStamp(o, author)); if (owedByAuthor) return `${author} reviewed yours, return the favor`; - const quickWin = p.sizeKnown && (p.weight === 'XS' || p.weight === 'S'); + const quickWin = p.weight === 'XS' || p.weight === 'S'; if (quickWin) return `Small one (${p.weight}), quick`; const days = Math.max(1, Math.round(p.ageDays)); return superlative @@ -394,7 +394,7 @@ export function readSignals(input: CheerInput): Signals { const quickWinCandidates = crSort( reviewableUnclaimed.filter( - p => !isBot(p) && p.sizeKnown && (p.weight === 'XS' || p.weight === 'S') + p => !isBot(p) && (p.weight === 'XS' || p.weight === 'S') ) ); const quickWinPull = quickWinCandidates[0] ?? null; diff --git a/frontend-v2/src/model/deal.ts b/frontend-v2/src/model/deal.ts index dbb77573..21261655 100644 --- a/frontend-v2/src/model/deal.ts +++ b/frontend-v2/src/model/deal.ts @@ -62,7 +62,7 @@ function score(p: DerivedPull, opts: DealRankOptions): number { const owedByAuthor = opts.pulls.some( other => other.data.user.login === opts.me && hasStamp(other, author) ); - const quickWin = p.sizeKnown && (p.weight === 'XS' || p.weight === 'S'); + const quickWin = p.weight === 'XS' || p.weight === 'S'; const base = urgency + (familiar ? 2 : 0) + (owedByAuthor ? 2 : 0) + (quickWin ? 1 : 0); // a demoted pull (a bot's) sinks below every non-demoted one no matter how // old, but keeps its relative order among the other demoted ones diff --git a/frontend-v2/src/model/sort.test.ts b/frontend-v2/src/model/sort.test.ts index 1abb155d..8c84a035 100644 --- a/frontend-v2/src/model/sort.test.ts +++ b/frontend-v2/src/model/sort.test.ts @@ -41,14 +41,6 @@ describe('crScore / crSort', () => { expect(crScore(oldM)).toBeLessThan(crScore(freshS)); }); - it('unknown size ranks as a medium guess, never XS-first', () => { - const unknown = fake({ sizeKnown: false, weight: 'XS', additions: null }); - const knownXs = fake({ weight: 'XS' }); - const knownL = fake({ weight: 'L' }); - expect(crScore(unknown)).toBeGreaterThan(crScore(knownXs)); - expect(crScore(unknown)).toBeLessThan(crScore(knownL)); - }); - it('sorts by score, then oldest first', () => { const a = fake({ weight: 'S', ageDays: 10 }); const b = fake({ weight: 'S', ageDays: 2 }); diff --git a/frontend-v2/src/model/sort.ts b/frontend-v2/src/model/sort.ts index e6aa1257..89583712 100644 --- a/frontend-v2/src/model/sort.ts +++ b/frontend-v2/src/model/sort.ts @@ -15,7 +15,7 @@ import { STARVE_DAYS, isIterating, weightRank, type DerivedPull } from './status * to the top of everyone's queue. */ export function crScore(p: DerivedPull): number { - const weight = p.sizeKnown ? weightRank(p.weight) : 2.5; + const weight = weightRank(p.weight); const req = p.data.status.cr_req; const oneFromDone = p.crHave > 0 && req - p.crHave === 1; const ageCredit = Math.min(p.ageDays / STARVE_DAYS, 2); diff --git a/frontend-v2/src/model/stack.test.ts b/frontend-v2/src/model/stack.test.ts index b86ed16d..011fbb53 100644 --- a/frontend-v2/src/model/stack.test.ts +++ b/frontend-v2/src/model/stack.test.ts @@ -71,7 +71,6 @@ function dp( conflict: false, mergeUnknown: false, dependent: !['main', 'master'].includes(baseRef), - sizeKnown: true, devBlockedBy: [], deployBlockedBy: [], qaingLogin: null, diff --git a/frontend-v2/src/model/stats.test.ts b/frontend-v2/src/model/stats.test.ts index a049813e..b8eaaa82 100644 --- a/frontend-v2/src/model/stats.test.ts +++ b/frontend-v2/src/model/stats.test.ts @@ -145,7 +145,8 @@ function full(over: { n?: number; ageDays?: number; weight?: Weight; - sizeKnown?: boolean; + additions?: number | null; + deletions?: number | null; crHave?: number; qaHave?: number; crReq?: number; @@ -165,7 +166,6 @@ function full(over: { status: over.status ?? 'needs_cr', ageDays: over.ageDays ?? 0, weight: over.weight ?? 'M', - sizeKnown: over.sizeKnown ?? true, crHave: over.crHave ?? 0, qaHave: over.qaHave ?? 0, recrBy: over.recrBy ?? [], @@ -179,6 +179,8 @@ function full(over: { data: { repo: over.repo ?? 'iFixit/ifixit', number: over.n ?? 1, + additions: over.additions === undefined ? 100 : over.additions, + deletions: over.deletions === undefined ? 20 : over.deletions, user: { login: over.author ?? 'alice' }, status: { cr_req: over.crReq ?? 2, @@ -243,7 +245,7 @@ describe('effortMix', () => { it('counts open pulls per weight, keeping zero classes, tracking estimates', () => { const mix = effortMix([ full({ weight: 'XS' }), - full({ weight: 'XS', sizeKnown: false }), + full({ weight: 'XS', additions: null, deletions: null }), full({ weight: 'XL' }), ]); expect(mix.buckets.map(b => b.count)).toEqual([2, 0, 0, 0, 1]); diff --git a/frontend-v2/src/model/stats.ts b/frontend-v2/src/model/stats.ts index 1159a2de..9e263726 100644 --- a/frontend-v2/src/model/stats.ts +++ b/frontend-v2/src/model/stats.ts @@ -230,7 +230,7 @@ export function effortMix(pulls: DerivedPull[]): EffortMix { let estimated = 0; for (const p of pulls) { by.set(p.weight, by.get(p.weight)! + 1); - if (!p.sizeKnown) estimated += 1; + if (p.data.additions == null && p.data.deletions == null) estimated += 1; } return { buckets: WEIGHTS.map(weight => ({ weight, count: by.get(weight)! })), estimated }; } diff --git a/frontend-v2/src/model/status.test.ts b/frontend-v2/src/model/status.test.ts index abf88829..a57419a5 100644 --- a/frontend-v2/src/model/status.test.ts +++ b/frontend-v2/src/model/status.test.ts @@ -491,11 +491,10 @@ describe('weight labels override the diff-size heuristic', () => { expect(derive(p, undefined, NOW).weight).toBe(reviewWeight(p)); }); - it('a label counts as a known size even with adds/dels off the wire', () => { + it('a label sets the class even with adds/dels off the wire', () => { const p = pull({ additions: null, deletions: null, labels: [wl('size: M')] }); const d = derive(p, undefined, NOW, STARVE_DAYS, MAP); expect(d.weight).toBe('M'); - expect(d.sizeKnown).toBe(true); }); it('weightFromLabels only matches configured titles', () => { diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index 4958c1a5..89e3b7b6 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -86,8 +86,6 @@ export interface DerivedPull { mergeUnknown: boolean; /** base isn't main/master: lands with its parent, gates "ready" */ dependent: boolean; - /** additions/deletions absent from the wire: weight and size sorts are guesses */ - sizeKnown: boolean; /** everyone holding an active dev block: the author owes them changes */ devBlockedBy: string[]; /** everyone holding an active deploy block: done, deliberately not shipped */ @@ -293,7 +291,6 @@ export function derive( // versioned with the labeller) and overrides the diff-size guess. It also // counts as a known size for the sort, even when adds/dels are off the wire. const labeledWeight = weightFromLabels(pull.labels, weightLabels); - const sizeKnown = labeledWeight != null || pull.additions != null || pull.deletions != null; const size = (pull.additions ?? 0) + (pull.deletions ?? 0); // Rot is rot whether the pull has zero stamps, one of two, or a stale one // waiting on a re-stamp — the old `crHave === 0` cliff hid half-reviewed @@ -333,7 +330,6 @@ export function derive( conflict, mergeUnknown: pull.mergeable == null, dependent, - sizeKnown, devBlockedBy, deployBlockedBy, qaingLogin: label(LABELS.qaing)?.user ?? null, @@ -379,22 +375,22 @@ export function reviewWeight(pull: PullData): Weight { export const weightRank = (w: Weight) => WEIGHT_RANK[w]; /** - * The session Weight filter's bucket for a pull: its weight letter - * (lowercase) when the size is known, else `'unknown'` — never the diff-size - * guess `reviewWeight()` falls back to for a missing size (additions/ - * deletions default to 0, which always reads XS, not a real class). Shared - * by the scoped-pull filter pass in app.tsx and WeightFilter's live + * The session Weight filter's bucket for a pull: its weight letter, + * lowercase. The old 'unknown' bucket is gone — the server now guarantees + * diff stats on every open pull (a list item that arrives without them gets + * the full pull fetched and grafted on), so every pull has a real class. + * Shared by the scoped-pull filter pass in app.tsx and WeightFilter's live * per-option counts, so the two can't disagree about which bucket a pull * lands in. */ -export function weightFilterKey(p: Pick): string { - return p.sizeKnown ? p.weight.toLowerCase() : 'unknown'; +export function weightFilterKey(p: Pick): string { + return p.weight.toLowerCase(); } /** Does this pull match any of the given Weight-filter selections? An empty * selection means no filter is applied. */ export function matchesWeightFilter( - p: Pick, + p: Pick, sel: readonly string[] ): boolean { return sel.length === 0 || sel.includes(weightFilterKey(p)); diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 112715c6..fdab10c9 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -155,8 +155,7 @@ export function Review({ [...list].sort( (a, b) => Number(!!a.qaingLogin) - Number(!!b.qaingLogin) || - (a.sizeKnown ? weightRank(a.weight) : 2.5) - - (b.sizeKnown ? weightRank(b.weight) : 2.5) || + weightRank(a.weight) - weightRank(b.weight) || b.ageDays - a.ageDays ); @@ -316,7 +315,7 @@ export function Review({ const whyQaNext = (p: DerivedPull) => p.qaingLogin ? `${p.qaingLogin} is already testing it; it sinks below unclaimed QA` - : p.sizeKnown && (p.weight === 'XS' || p.weight === 'S') + : p.weight === 'XS' || p.weight === 'S' ? `Nobody's testing it yet, a light one (${p.weight})` : `Nobody's testing it yet, waiting ${Math.max(1, Math.round(p.ageDays))}d`; diff --git a/lib/git-manager.js b/lib/git-manager.js index 7c166fdc..048a4c24 100644 --- a/lib/git-manager.js +++ b/lib/git-manager.js @@ -282,6 +282,18 @@ export default { var repo = githubPull.base.repo.full_name; + // pulls.list items carry no additions/deletions/changed_files — only + // pulls.get and webhook payloads do — so the bulk open-pulls refresh + // used to REPLACE good sizes with NULL and the board showed "size + // unknown". When the stats are missing, fetch the full pull once and + // graft them on; a payload that already has them costs nothing extra. + var diffStats = + githubPull.additions === undefined + ? githubRest.pulls + .get(params({ pull_number: githubPull.number }, repo)) + .then(res => res.data) + : Promise.resolve(githubPull); + var reviewComments = getPullReviewComments(repo, githubPull.number); var comments = getIssueComments(repo, githubPull.number); var headCommit = getCommit(repo, githubPull.head.sha); @@ -308,6 +320,7 @@ export default { ghIssue, reviews, botLogin, + diffStats, ]).then(function (results) { var reviewComments = results[0], comments = results[1], @@ -317,7 +330,13 @@ export default { events = results[5], ghIssue = results[6], reviews = results[7], - botLogin = results[8]; + botLogin = results[8], + fullPull = results[9]; + + // graft the diff stats a list item lacks (see diffStats above) + githubPull.additions = fullPull.additions; + githubPull.deletions = fullPull.deletions; + githubPull.changed_files = fullPull.changed_files; // Array of Signature objects. var commentSignatures = comments.reduce(function (sigs, comment) { diff --git a/models/pull.js b/models/pull.js index 14045f49..33cabd72 100644 --- a/models/pull.js +++ b/models/pull.js @@ -382,6 +382,9 @@ class Pull { difficulty: data.difficulty, additions: data.additions, deletions: data.deletions, + // was dropped on the DB round-trip: DBPull writes it, but a restart + // read it back as undefined, losing the >15-files weight bump + changed_files: data.changed_files, milestone: { title: data.milestone_title, due_on: utils.fromUnixTime(data.milestone_due_on), From 7cf9588e8347bd80dbd0ad0790665a525967a473 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 14:45:55 -0700 Subject: [PATCH 275/381] Row actions split: worded verbs on hover, plumbing in the kebab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four identical icon buttons (copy, snooze, refresh, claim) gave equal salience to unequal decisions: Claim and Snooze decide something about the work, copy and re-fetch are plumbing you reach for rarely. The hover cluster now shows only the verbs, as WORDS — "Snooze" in quiet ink, "Claim" in brand, because committing to a review is the board's core gesture and a word invites where an anonymous hand icon didn't. The cluster floats over the row's tail at zero standing width (the compact overlay promoted to the only mode), so the words cost nothing at rest. The utilities move to the kebab at every width — one overflow pattern board-wide, hover-revealed on desktop (extending the narrow-column pattern), always visible on phones. Its menu reorders to match: the verbs lead (Claim, then Snooze), a divider, then copy / re-fetch, then repo and person management. Snooze also becomes context-honest: it renders only on the Review lens (opts.showSnooze), the one lens a snooze quiets — offering it on Classic while it silently acted elsewhere was a lie of placement. The held-claim hand stays always-visible outside the hover cluster, as before: a commitment, not a hover affordance. DESIGN.md records the verbs-vs-plumbing rule. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 10 ++ frontend-v2/src/app.tsx | 7 +- frontend-v2/src/components/Row.tsx | 180 +++++++++++++---------------- frontend-v2/src/styles.css | 45 +++----- 4 files changed, 114 insertions(+), 128 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index fb93bdb5..2ab51429 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -83,6 +83,16 @@ remains: a ranked lane IS the recommendation — put pick logic in the lane's ordering and explain it behind the sub-line, never inside a control with its own private order. +**Workflow verbs are words; plumbing is an overflow.** A row action that +decides something about the work (Claim, Snooze) reads as a worded +invitation on hover — the word is the affordance, Claim in brand because +committing to a review is the board's core gesture. A utility (copy the +branch, re-fetch) is plumbing: it lives in the kebab at every width, one +overflow pattern board-wide, verbs at the top of its menu. And a verb only +renders where it acts: Snooze appears on the Review lens alone, because +that is the only lens a snooze quiets. Four identical icon buttons was the +failure mode this replaces — equal salience for unequal decisions. + **Work-in-progress lives inline, not in a popover.** A popover is for glancing (state detail, signatures, rankings) and rightly dies on any outside click. Anything the user is mid-way through renders in the board's diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 25cd3c07..198a8e36 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -1050,7 +1050,12 @@ export function App() {
)} {initialized && lens === 'review' && ( - + )} {initialized && lens === 'mine' && ( diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index cffab9ff..1ae5214d 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -56,6 +56,9 @@ export interface RowOptions { ageRotDays?: number; /** which clock the age numeral shows (settings.ageDisplay) */ ageDisplay?: 'opened' | 'updated'; + /** offer the Snooze verb on rows — true only on the Review lens, the + * one lens a snooze quiets */ + showSnooze?: boolean; /** the board's longest-open pull, in days — the age baseline's full * track; every row's line is a fraction of the oldest */ maxAgeDays?: number; @@ -290,29 +293,29 @@ function useRowActions(pull: DerivedPull) { } /** - * Hover/focus actions: copy the branch name, snooze, re-fetch from GitHub. - * `overlay` (compact) floats them over the row's tail instead of reserving - * rail width — .row-actions hides them with visibility, which still takes - * layout space, and in a narrow column that standing ~70px tax comes straight - * out of the title. Hidden below 720px AND in narrow (≤520px) containers, - * where RowActionsKebab takes over — hover-reveal has no phone story, and a - * wrapped rail has no room for four inline icons. + * The row's workflow verbs, revealed on hover/focus as WORDS — Snooze and + * Claim are decisions about the work, so they read as invitations, not + * anonymous icons. Utilities (copy branch, re-fetch) are plumbing and live + * in the kebab only. The cluster always floats over the row's tail + * (absolute, zero standing width), so the words cost nothing at rest. + * Snooze renders only where it acts (the Review lens: opts.showSnooze); + * Claim only on another person's claimable pull. Hidden below 720px, where + * the kebab is the whole story. */ function RowActions({ pull, - overlay, me, claim, + showSnooze, }: { pull: DerivedPull; - overlay?: boolean; me: string; claim: Claim | null; + showSnooze?: boolean; }) { const a = useRowActions(pull); - const btn = - 'hit pressable rounded border-0 bg-transparent px-1 text-xs text-ink-3 hover:text-brand'; const mine = claim?.login === me; + const claimable = pull.data.user.login !== me && !mine; return ( <> {/* always visible — not .row-actions — so a claim you hold doesn't @@ -329,71 +332,36 @@ function RowActions({ )} - + )} ); } @@ -407,7 +375,15 @@ function RowActions({ * takes over, revealed on row hover / focus (styles.css .pd-kebab). In wide * lanes the hover cluster remains the desktop home and the kebab stays gone. */ -function RowActionsKebab({ pull, claim }: { pull: DerivedPull; claim: Claim | null }) { +function RowActionsKebab({ + pull, + claim, + showSnooze, +}: { + pull: DerivedPull; + claim: Claim | null; + showSnooze?: boolean; +}) { const a = useRowActions(pull); const settings = useSettings(); const { me } = usePulldasher(); @@ -424,7 +400,7 @@ function RowActionsKebab({ pull, claim }: { pull: DerivedPull; claim: Claim | nu )} > - - - {/* never offered on your own pull — you don't review yourself */} {author !== me && ( + )} +
+ +
+ )} + {mine ? ( - )} - {(showSnooze || claimable) && ( - - {showSnooze && ( - - )} - {claimable && ( - - )} + ) : claimable ? ( + + ) : ( + + Claim )} - + ); } @@ -591,7 +589,6 @@ function MetricRail({ opts.compact ? 'relative' : '' }`} > - {/* one instrument, humans first: CR (label, weight letter, pips — one door into one panel), then QA, then the machine's circle at @@ -648,6 +645,7 @@ function MetricRail({ rotDays={opts.ageRotDays} display={opts.ageDisplay} /> + ); } diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index f7980e55..5beb6e9e 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -425,9 +425,6 @@ label:has(> input:not(:disabled)) { desktop too — revealed on row hover / focus, opacity so the slot never jumps. Unlayered on purpose: it must beat the kebab's own hidden-at-desktop utility. */ - .row-actions { - display: none; - } .pd-rail { flex-basis: 100%; margin-left: 0; @@ -452,20 +449,22 @@ label:has(> input:not(:disabled)) { z-index: 1; } -/* hover-reveal row verbs (Snooze / Claim). The row-level :focus-within - matters: a visibility:hidden element can never take focus, so the buttons - must become visible when ANY part of the row is focused (tabbing to the - title), or the keyboard could never reach them. */ -@media (hover: hover) { - .pd-row:hover .row-actions { - visibility: visible; - } +/* the verb dock's standing words (Claim / Snooze): whispered at rest — + readable cold, so the affordance is discoverable without hover, on touch + included — rising to their true colors on row hover or focus. Claim takes + brand (the invitation); Snooze takes ink. The same rest-then-rise ramp + the age numeral wears: one quiet tier, board-wide. */ +.pd-verb { + color: color-mix(in oklab, var(--ink-3) 60%, transparent); + transition: color 150ms ease-out; } -.pd-row:focus-within .row-actions { - visibility: visible; +.pd-row:hover .pd-verb, +.pd-verb:focus-visible { + color: var(--ink-2); } -.row-actions { - visibility: hidden; +.pd-row:hover .pd-verb-claim, +.pd-verb-claim:focus-visible { + color: var(--brand); } /* the kebab (utilities + management) reveals on row hover at every desktop From 4527a43f9e5cccd5d611c7f383978bc9a472d938 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 14:57:50 -0700 Subject: [PATCH 278/381] Verbs: the slot stands, the words fade in; held claims join the CR pips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standing whispered words put quiet texture on every row of a lane — sixty Snooze/Claims of it. The dock now separates PLACE from PRESENCE: the slot keeps its reserved width (one column down the lane, nothing ever shifts), while the words rest at opacity 0 and fade in on row hover or focus. A fade, not an animate-in: opacity is the board's one reveal mechanism (the green CI ring, the kebab), and motion stays reserved for state changes — nothing slides because a cursor passed. On touch, where hover doesn't exist, the words stay visible at a whisper. With the dock quiet at rest, the held claim needed a standing home that keeps the column's vertical rhythm — and it has a semantic one: a claim is literally a pending review request, so its mark is now the brand hand riding with the CR pips. "Release" joins the dock as a normal revealed verb. Commitments stay always-visible; the dock stays empty until a row has your attention. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 33 +++++++++++++++++------------ frontend-v2/src/components/Row.tsx | 34 ++++++++++++++++++++---------- frontend-v2/src/styles.css | 31 ++++++++++++++++----------- 3 files changed, 61 insertions(+), 37 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index af587209..f57a4f5b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -83,20 +83,25 @@ remains: a ranked lane IS the recommendation — put pick logic in the lane's ordering and explain it behind the sub-line, never inside a control with its own private order. -**Workflow verbs are first-class: standing words in the row's anatomy.** -Claim and Snooze are the board's two gestures, so they live in the rail's -last column — the verb dock — like CR and age do, not in anything that -materializes on hover. At rest they WHISPER (60% ink-3: readable cold, so -the affordance is discoverable without hover, touch included); on row -hover each rises to its true color — Claim to brand, the invitation, -Snooze to ink. A claim you hold turns the slot into "Release" in standing -brand: a commitment is never hidden. The dock is one column down a lane -(the Claim slot is reserved even where unclaimable), a verb renders only -where it acts (Snooze on Review alone), and utilities (copy branch, -re-fetch) stay in the kebab at every width, verbs atop its menu. Two -failure modes this replaces: four identical icons gave equal salience to -unequal decisions, and a hover-revealed pill made the board's core gesture -undiscoverable at rest. +**Workflow verbs are first-class: a standing place, a resting quiet.** +Claim and Snooze are the board's two gestures, so they own the rail's +last column — the verb dock — like CR and age do. The SLOT stands (its +width is reserved even when empty, so the dock is one column down a lane +and nothing ever shifts); the WORDS rest invisible and fade in on row +hover or focus — opacity only, the board's one reveal mechanism (the +green CI ring, the kebab); motion stays reserved for state changes, so +nothing slides or grows because a cursor passed. Claim and Release wear +brand (the invitation and its undo); Snooze, ink. On touch, where hover +doesn't exist, the words stay visible at a whisper. A claim you HOLD is a +commitment, and commitments never hide: its standing mark is the brand +hand riding with the CR pips — a claim is literally a pending review +request, so the CR cluster is its semantic home — while "Release" +reveals in the dock like its sibling verbs. A verb renders only where it +acts (Snooze on Review alone); utilities (copy branch, re-fetch) stay in +the kebab at every width, verbs atop its menu. Failure modes this design +retires: four identical icons (equal salience, unequal decisions), a +hover pill (core gesture undiscoverable, layout that materializes), and +sixty standing words down a lane (texture where quiet belongs). **Work-in-progress lives inline, not in a popover.** A popover is for glancing (state detail, signatures, rankings) and rightly dies on any diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 855eeecc..661f325f 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -294,15 +294,19 @@ function useRowActions(pull: DerivedPull) { /** * The verb dock: the rail's last column, where the row's workflow verbs - * STAND — Claim and Snooze are the board's two first-class gestures, so - * they live in the row's anatomy like CR and age do, not in a pill that - * materializes on hover. At rest they whisper (readable, so the affordance - * is discoverable cold, on touch included); on row hover each rises to its - * true color — Claim to brand, the invitation, Snooze to ink. A claim you - * hold turns the slot into "Release" in STANDING brand: a commitment is - * never hidden behind a hover. Snooze renders only where it acts (the - * Review lens); the Claim slot is reserved even on unclaimable rows so the - * dock reads as one column down a lane. Utilities stay in the kebab. + * live — Claim and Snooze are the board's two first-class gestures, so + * they hold a place in the row's anatomy like CR and age do. The place + * STANDS; the words REST INVISIBLE and fade in on row hover/focus + * (opacity only — the board's established reveal mechanism; motion stays + * reserved for state changes), so a scanned lane is quiet and the verbs + * appear exactly when a row has your attention. On touch, where hover + * doesn't exist, they stay visible at the whisper tier. Claim wears brand + * (the invitation); Snooze, ink. A claim you HOLD is a commitment, and + * commitments never hide: its standing mark is the brand hand riding with + * the CR pips (a claim is literally a pending review request), while + * "Release" reveals here like its sibling verbs. Snooze renders only + * where it acts (Review); slots are reserved even when empty so the dock + * is one column down a lane. Utilities stay in the kebab. */ function VerbDock({ pull, @@ -336,10 +340,9 @@ function VerbDock({ ) : claimable ? ( @@ -621,6 +624,15 @@ function MetricRail({ } panelExtra={} /> + {claim?.login === me && ( + + + + )} input:not(:disabled)) { z-index: 1; } -/* the verb dock's standing words (Claim / Snooze): whispered at rest — - readable cold, so the affordance is discoverable without hover, on touch - included — rising to their true colors on row hover or focus. Claim takes - brand (the invitation); Snooze takes ink. The same rest-then-rise ramp - the age numeral wears: one quiet tier, board-wide. */ +/* the verb dock's words (Claim / Snooze / Release): the SLOT stands (the + dock reserves its width, so nothing ever shifts), the WORDS rest + invisible and fade in on row hover or focus — opacity only, the board's + established reveal (the green CI ring, the kebab); motion stays reserved + for state changes. Claim/Release wear brand (the invitation and its + undo); Snooze, ink. On touch, where hover doesn't exist, the words stay + visible at the whisper tier instead. */ .pd-verb { - color: color-mix(in oklab, var(--ink-3) 60%, transparent); - transition: color 150ms ease-out; -} -.pd-row:hover .pd-verb, -.pd-verb:focus-visible { + opacity: 0; color: var(--ink-2); + transition: opacity 150ms ease-out; } -.pd-row:hover .pd-verb-claim, -.pd-verb-claim:focus-visible { +.pd-verb-claim { color: var(--brand); } +.pd-row:hover .pd-verb, +.pd-row:focus-within .pd-verb { + opacity: 1; +} +@media (hover: none) { + .pd-verb { + opacity: 0.55; + } +} /* the kebab (utilities + management) reveals on row hover at every desktop width — one overflow pattern board-wide; below 720px it's always visible From 85e1674309bcf195407346879f40bb14568153bb Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 15:01:03 -0700 Subject: [PATCH 279/381] Verbs float left of the rail; the right edge returns to the data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reserved dock was a worse design (owner call): it held ~100px of invisible words at the row's end, which pushed the age numeral — the board's strongest column, the right-edge anchor — inward and paid dead space on every row for it. A place is only worth standing if something visible stands in it. The verbs keep their fade-on-row-hover reveal but now own no geometry at all: they float just left of the rail (absolute off the rail's own box, both densities), on a muted backdrop for legibility over the meta text beneath, with pointer-events following the fade so an invisible word can never steal a click from the text under it. The rail reads CR · QA · CI · age flush to the right edge again — verified one exact x for every age numeral down the lane — and appears exactly where the eye already is when a row has your attention. Touch keeps the kebab as the verbs' labeled home (the float doesn't exist there); the held claim's brand hand stays standing with the CR pips. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 40 +++++++++++++------------ frontend-v2/src/components/Row.tsx | 47 +++++++++++++----------------- frontend-v2/src/styles.css | 30 +++++++++---------- 3 files changed, 56 insertions(+), 61 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index f57a4f5b..218e9439 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -83,25 +83,27 @@ remains: a ranked lane IS the recommendation — put pick logic in the lane's ordering and explain it behind the sub-line, never inside a control with its own private order. -**Workflow verbs are first-class: a standing place, a resting quiet.** -Claim and Snooze are the board's two gestures, so they own the rail's -last column — the verb dock — like CR and age do. The SLOT stands (its -width is reserved even when empty, so the dock is one column down a lane -and nothing ever shifts); the WORDS rest invisible and fade in on row -hover or focus — opacity only, the board's one reveal mechanism (the -green CI ring, the kebab); motion stays reserved for state changes, so -nothing slides or grows because a cursor passed. Claim and Release wear -brand (the invitation and its undo); Snooze, ink. On touch, where hover -doesn't exist, the words stay visible at a whisper. A claim you HOLD is a -commitment, and commitments never hide: its standing mark is the brand -hand riding with the CR pips — a claim is literally a pending review -request, so the CR cluster is its semantic home — while "Release" -reveals in the dock like its sibling verbs. A verb renders only where it -acts (Snooze on Review alone); utilities (copy branch, re-fetch) stay in -the kebab at every width, verbs atop its menu. Failure modes this design -retires: four identical icons (equal salience, unequal decisions), a -hover pill (core gesture undiscoverable, layout that materializes), and -sixty standing words down a lane (texture where quiet belongs). +**Workflow verbs are first-class — and own no geometry.** Claim and +Snooze are the board's two gestures; the rail's right edge is the +board's strongest column (the age numeral caps it) and belongs to DATA +alone. So the verbs float just left of the rail, fading in on row hover +or focus — opacity only, the board's one reveal mechanism; motion stays +reserved for state changes — on a muted backdrop that keeps them legible +over the meta text beneath. Nothing is reserved, nothing shifts, every +fact column stays flush. Pointer-events follow the fade, so an invisible +word can never steal a click. Claim and Release wear brand (the +invitation and its undo); Snooze, ink. A claim you HOLD is a commitment, +and commitments never hide: its standing mark is the brand hand riding +with the CR pips — a claim is literally a pending review request, so the +CR cluster is its semantic home. Touch has no hover: the kebab is the +verbs' labeled home there. A verb renders only where it acts (Snooze on +Review alone); utilities stay in the kebab at every width, verbs atop +its menu. Failure modes this design retires, in order tried: four +identical icons (equal salience, unequal decisions); sixty standing +words down a lane (texture where quiet belongs); and a reserved +invisible slot at the row's end (it pushed the age column off the right +edge and paid dead space for words that weren't there — a place is only +worth standing if something visible stands in it). **Work-in-progress lives inline, not in a popover.** A popover is for glancing (state detail, signatures, rankings) and rightly dies on any diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 661f325f..8ef1a339 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -293,20 +293,20 @@ function useRowActions(pull: DerivedPull) { } /** - * The verb dock: the rail's last column, where the row's workflow verbs - * live — Claim and Snooze are the board's two first-class gestures, so - * they hold a place in the row's anatomy like CR and age do. The place - * STANDS; the words REST INVISIBLE and fade in on row hover/focus - * (opacity only — the board's established reveal mechanism; motion stays - * reserved for state changes), so a scanned lane is quiet and the verbs - * appear exactly when a row has your attention. On touch, where hover - * doesn't exist, they stay visible at the whisper tier. Claim wears brand - * (the invitation); Snooze, ink. A claim you HOLD is a commitment, and - * commitments never hide: its standing mark is the brand hand riding with - * the CR pips (a claim is literally a pending review request), while - * "Release" reveals here like its sibling verbs. Snooze renders only - * where it acts (Review); slots are reserved even when empty so the dock - * is one column down a lane. Utilities stay in the kebab. + * The row's workflow verbs — Claim and Snooze, the board's two first-class + * gestures. They own no geometry: the data rail's right edge is the + * board's strongest column (the age numeral caps it), so the verbs FLOAT + * just left of the rail, fading in on row hover/focus (opacity only — + * the board's one reveal mechanism; motion stays reserved for state + * changes) on a muted backdrop that keeps them legible over the meta + * text beneath. Nothing is reserved, nothing ever shifts, every fact + * column stays aligned. Pointer-events follow the fade so the invisible + * words can never steal a click. Claim and Release wear brand (the + * invitation and its undo); Snooze, ink. A claim you HOLD is a + * commitment and commitments never hide: its standing mark is the brand + * hand riding with the CR pips (a claim is literally a pending review + * request). Touch has no hover, so there the verbs live in the kebab + * alone, labeled. Snooze renders only where it acts (Review). */ function VerbDock({ pull, @@ -322,15 +322,16 @@ function VerbDock({ const a = useRowActions(pull); const mine = claim?.login === me; const claimable = pull.data.user.login !== me && !mine; + if (!showSnooze && !claimable && !mine) return null; const verb = 'hit pressable rounded border-0 bg-transparent px-0.5 text-xs whitespace-nowrap'; return ( - + {showSnooze && ( - ) : ( - - Claim - - )} + ) : null} ); } @@ -588,9 +585,7 @@ function MetricRail({ const me = opts.me; return ( {/* one instrument, humans first: CR (label, weight letter, pips — diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index c9fbadb6..3b232d26 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -449,28 +449,26 @@ label:has(> input:not(:disabled)) { z-index: 1; } -/* the verb dock's words (Claim / Snooze / Release): the SLOT stands (the - dock reserves its width, so nothing ever shifts), the WORDS rest - invisible and fade in on row hover or focus — opacity only, the board's - established reveal (the green CI ring, the kebab); motion stays reserved - for state changes. Claim/Release wear brand (the invitation and its - undo); Snooze, ink. On touch, where hover doesn't exist, the words stay - visible at the whisper tier instead. */ -.pd-verb { +/* the row's verbs (Claim / Snooze / Release) float left of the rail with + ZERO standing geometry — the rail's right edge is the board's strongest + column and stays the data's alone. They fade in on row hover or focus + (opacity only, the board's one reveal mechanism); pointer-events follow + the fade so invisible words never steal a click from the meta text they + float over. Touch has no hover: the kebab is the verbs' labeled home + there, so the float simply doesn't exist. */ +.pd-verbs { opacity: 0; - color: var(--ink-2); + pointer-events: none; transition: opacity 150ms ease-out; } -.pd-verb-claim { - color: var(--brand); -} -.pd-row:hover .pd-verb, -.pd-row:focus-within .pd-verb { +.pd-row:hover .pd-verbs, +.pd-row:focus-within .pd-verbs { opacity: 1; + pointer-events: auto; } @media (hover: none) { - .pd-verb { - opacity: 0.55; + .pd-verbs { + display: none; } } From d3cfba99e92ca379cd69e1e817ec8ee86b01c800 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 15:09:39 -0700 Subject: [PATCH 280/381] The hand retires; claim and snooze become word toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verbs now name the exit, not a glyph: a claimed row's slot reads "Release", a snoozed row's reads "Unsnooze" (new unsnoozePull in the store, publishing like its siblings so the row walks straight back into the lanes) — the same slot, the opposite word. The hand icon is gone everywhere: the CR-cluster standing mark, the kebab item, the Legend (whose claim row now shows the word the UI actually renders, in brand). A claimed row already reads through position — it groups under Finish CR in Waiting on you — so a second standing mark restated what structure says. The kebab's snooze item carries the same toggled word, and per-row wake means the snoozed fold no longer needs the all-or- nothing Wake all as its only undo. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 12 ++++---- frontend-v2/src/components/Legend.tsx | 10 ++----- frontend-v2/src/components/Row.tsx | 40 +++++++++++++++------------ frontend-v2/src/store.ts | 6 ++++ 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 218e9439..00776fa2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -92,11 +92,13 @@ reserved for state changes — on a muted backdrop that keeps them legible over the meta text beneath. Nothing is reserved, nothing shifts, every fact column stays flush. Pointer-events follow the fade, so an invisible word can never steal a click. Claim and Release wear brand (the -invitation and its undo); Snooze, ink. A claim you HOLD is a commitment, -and commitments never hide: its standing mark is the brand hand riding -with the CR pips — a claim is literally a pending review request, so the -CR cluster is its semantic home. Touch has no hover: the kebab is the -verbs' labeled home there. A verb renders only where it acts (Snooze on +invitation and its undo); Snooze, ink. The verbs are TOGGLES that name +the exit: a claimed row's verb reads Release, a snoozed row's reads +Unsnooze — the same slot, the opposite word, no state glyph (the hand +icon was retired outright; a claimed row already reads through position, +grouping under Finish CR in Waiting on you, so a second mark restated +what structure says). Touch has no hover: the kebab is the verbs' +labeled home there, and its items carry the same toggled words. A verb renders only where it acts (Snooze on Review alone); utilities stay in the kebab at every width, verbs atop its menu. Failure modes this design retires, in order tried: four identical icons (equal salience, unequal decisions); sixty standing diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index cdda14ac..76053b96 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { CircleHelp, Hand } from 'lucide-react'; +import { CircleHelp } from 'lucide-react'; import { Icon } from './Icon'; import { Popover } from './Popover'; import { eyebrowText } from './WordGroups'; @@ -127,12 +127,8 @@ export function Legend() { - - - } - def="claim a review: adds you as a reviewer on the PR, so GitHub and the board both show you’re on it. Clears when you submit, release, or are removed" + term={Claim} + def="hover a row for its verbs. Claiming adds you as a reviewer on the PR, so GitHub and the board both show you’re on it; the row moves to Waiting on you until you submit, release, or are removed" /> requested from you} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 8ef1a339..996394ae 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -1,5 +1,5 @@ import { memo, useState, type ReactNode } from 'react'; -import { AlarmClock, Copy, Diamond, EllipsisVertical, Hand, RefreshCw, Star } from 'lucide-react'; +import { AlarmClock, Copy, Diamond, EllipsisVertical, RefreshCw, Star } from 'lucide-react'; import type { DerivedPull } from '../model/status'; import { isIterating, lastPushEpoch, weightFilterKey } from '../model/status'; import { type Claim, rowNote } from '../model/actions'; @@ -18,9 +18,11 @@ import { claimFor, claimReview, isFresh, + isSnoozed, refreshPull, releaseReview, snoozePull, + unsnoozePull, usePulldasher, } from '../store'; import { @@ -282,6 +284,7 @@ function useRowActions(pull: DerivedPull) { setTimeout(() => setCopied(false), 1200); }, snooze: () => snoozePull(pullKey(pull.data)), + unsnooze: () => unsnoozePull(pullKey(pull.data)), refresh: () => { refreshPull(pull.data.repo, pull.data.number); setSpinning(true); @@ -320,6 +323,8 @@ function VerbDock({ showSnooze?: boolean; }) { const a = useRowActions(pull); + const { snoozed } = usePulldasher(); + const snoozedNow = isSnoozed(pull.data, snoozed); const mine = claim?.login === me; const claimable = pull.data.user.login !== me && !mine; if (!showSnooze && !claimable && !mine) return null; @@ -330,11 +335,15 @@ function VerbDock({ {showSnooze && ( )} {mine ? ( @@ -384,7 +393,8 @@ function RowActionsKebab({ }) { const a = useRowActions(pull); const settings = useSettings(); - const { me } = usePulldasher(); + const { me, snoozed } = usePulldasher(); + const snoozedNow = isSnoozed(pull.data, snoozed); const repo = pull.data.repo; const author = pull.data.user.login; const repoLabel = shortRepo(repo); @@ -429,14 +439,17 @@ function RowActionsKebab({ : "claim this review, flags that you're reading it" } > - {claimedByMe ? 'Release claim' : 'Claim review'} )} {showSnooze && ( - )}
@@ -619,15 +632,6 @@ function MetricRail({ } panelExtra={} /> - {claim?.login === me && ( - - - - )} Date: Wed, 22 Jul 2026 15:22:19 -0700 Subject: [PATCH 281/381] Claimed and snoozed stand as brand flags in the meta line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The states you choose deserved a standing record, not just a toggled hover verb: "claimed" and "snoozed" now render as flags beside conflicts and stacked — the board's existing channel for standing row facts — in a new brand flag tone, because brand is the yours color and a choice you made stays visible with no hover required. The flags ride the shared flag popover (brand dot, one-line meaning pointing at the hover verbs for the exit) and appear on every lens the row renders on, so a snoozed pull seen on Classic now says why it's missing from Review. The hover verbs (Release / Unsnooze) are unchanged: state stands in the meta line, the exit reveals on approach. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 8 ++-- frontend-v2/src/components/Legend.tsx | 2 +- frontend-v2/src/components/Row.tsx | 55 ++++++++++++++++++++++++--- frontend-v2/src/styles.css | 4 ++ 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 00776fa2..cca4d461 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -95,9 +95,11 @@ word can never steal a click. Claim and Release wear brand (the invitation and its undo); Snooze, ink. The verbs are TOGGLES that name the exit: a claimed row's verb reads Release, a snoozed row's reads Unsnooze — the same slot, the opposite word, no state glyph (the hand -icon was retired outright; a claimed row already reads through position, -grouping under Finish CR in Waiting on you, so a second mark restated -what structure says). Touch has no hover: the kebab is the verbs' +icon was retired outright). The STANDING record of the choice is a brand +FLAG in the meta line — "claimed" / "snoozed" beside conflicts and +stacked — because flags are the board's channel for standing row facts, +and brand is the yours color: a state you chose stays visible on every +lens, in blue, with no hover required. Touch has no hover: the kebab is the verbs' labeled home there, and its items carry the same toggled words. A verb renders only where it acts (Snooze on Review alone); utilities stay in the kebab at every width, verbs atop its menu. Failure modes this design retires, in order tried: four diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 76053b96..ac849e16 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -121,7 +121,7 @@ export function Legend() { stacked } - def="amber flag: act on it. Gray flag: a plain fact" + def="amber flag: act on it. Gray flag: a plain fact. Brand flag (claimed, snoozed): a standing state you chose" /> diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 996394ae..2228f002 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -110,8 +110,10 @@ function flashOnce(key: string, fresh: boolean): boolean { interface Flag { key: string; - /** 'warn' = act on it (amber); 'note' = a neutral fact (muted) */ - tone: 'warn' | 'note'; + /** 'warn' = act on it (amber); 'note' = a neutral fact (muted); + * 'brand' = a standing state YOU chose (claimed, snoozed) — brand is the + * yours color, and a choice you made never hides behind a hover */ + tone: 'warn' | 'note' | 'brand'; label: string; detail: ReactNode; } @@ -133,10 +135,28 @@ function rowFlags( pull: DerivedPull, showIterating: boolean, depth: number, - orphanParent: ParentRef | null + orphanParent: ParentRef | null, + claimedByMe = false, + snoozedNow = false ): Flag[] { const p = pull; const flags: Flag[] = []; + if (claimedByMe) + flags.push({ + key: 'claimed', + tone: 'brand', + label: 'claimed', + detail: + 'You claimed this review — GitHub and the board both show you’re on it. Hover the row for Release.', + }); + if (snoozedNow) + flags.push({ + key: 'snoozed', + tone: 'brand', + label: 'snoozed', + detail: + 'Off your Review lens until tomorrow or its next change. Hover the row for Unsnooze.', + }); if (p.conflict && p.status !== 'unmergeable') flags.push({ key: 'conflicts', @@ -236,7 +256,13 @@ function RowDetails({ flags }: { flags: Flag[] }) { {flags.map(f => ( {f.label} @@ -249,7 +275,14 @@ function RowDetails({ flags }: { flags: Flag[] }) { {f.label} {f.detail} @@ -680,6 +713,7 @@ function RowImpl({ // both optional, so a lens that hasn't threaded them (yet) just sees null // and rowNote falls back to its base (pre-coordination) note const claim = claimFor(d); + const { snoozed: snoozedMap } = usePulldasher(); const turn = opts.turns?.get(key) ?? null; const poolSize = opts.pools?.get(d.repo)?.length ?? 0; // the viewer-relative note (model/actions.ts): never both-null for an open @@ -775,7 +809,16 @@ function RowImpl({ )} - + } rail={} diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 3b232d26..b14391bc 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -365,6 +365,10 @@ label:has(> input:not(:disabled)) { /* row flags: quiet colored labels, not badges — amber for act-on-it, muted ink for neutral facts. The cluster's hover popover carries meaning. */ + .flag-brand { + @apply text-[11px] font-medium; + color: var(--brand); + } .flag-warn { @apply text-[11px] font-medium; color: var(--warn); From 6d370dde942a21cc44d5d595d76d9bd48b7f492d Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 15:26:58 -0700 Subject: [PATCH 282/381] Verb chips: a button stands when it records, reveals when it offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The meta-line flags lasted one commit — restating in the meta what a standing button can say itself split one fact across two homes (owner call). The claimed/snoozed record now lives IN the buttons: the float splits into two independent chips, each with one rule. A chip that RECORDS a choice you made stands — a claimed row always shows "Release", a snoozed row always shows "Unsnooze", brand and ink respectively, no hover needed, and if both states are yours both chips stand. A chip that merely OFFERS ("Claim", "Snooze") reveals on row hover/focus, opacity-only with pointer-events following. The state and its exit are the same pixel. The brand flag tone, its CSS, the rowFlags claimed/snoozed entries, and the Legend's brand-flag sentence all revert; touch keeps offers in the kebab while standing exits stay visible everywhere. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 19 ++-- frontend-v2/src/components/Legend.tsx | 2 +- frontend-v2/src/components/Row.tsx | 141 +++++++++----------------- frontend-v2/src/styles.css | 26 ++--- 4 files changed, 71 insertions(+), 117 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index cca4d461..adb675c1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -92,15 +92,16 @@ reserved for state changes — on a muted backdrop that keeps them legible over the meta text beneath. Nothing is reserved, nothing shifts, every fact column stays flush. Pointer-events follow the fade, so an invisible word can never steal a click. Claim and Release wear brand (the -invitation and its undo); Snooze, ink. The verbs are TOGGLES that name -the exit: a claimed row's verb reads Release, a snoozed row's reads -Unsnooze — the same slot, the opposite word, no state glyph (the hand -icon was retired outright). The STANDING record of the choice is a brand -FLAG in the meta line — "claimed" / "snoozed" beside conflicts and -stacked — because flags are the board's channel for standing row facts, -and brand is the yours color: a state you chose stays visible on every -lens, in blue, with no hover required. Touch has no hover: the kebab is the verbs' -labeled home there, and its items carry the same toggled words. A verb renders only where it acts (Snooze on +invitation and its undo); Snooze, ink. Each chip follows ONE rule: it +STANDS when it records a choice you made — a claimed row always shows +"Release", a snoozed row always shows "Unsnooze", in place, no hover — +and reveals on approach when it merely OFFERS one ("Claim", "Snooze"). +The state and its exit are the same pixel: no state glyph (the hand +icon was retired outright), no meta flag restating it (tried and cut — +the standing chip already IS the record). If both states are yours, +both chips stand. Touch has no hover: offers live in the kebab there, +labeled and carrying the same toggled words; standing exits remain +visible everywhere. A verb renders only where it acts (Snooze on Review alone); utilities stay in the kebab at every width, verbs atop its menu. Failure modes this design retires, in order tried: four identical icons (equal salience, unequal decisions); sixty standing diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index ac849e16..76053b96 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -121,7 +121,7 @@ export function Legend() { stacked } - def="amber flag: act on it. Gray flag: a plain fact. Brand flag (claimed, snoozed): a standing state you chose" + def="amber flag: act on it. Gray flag: a plain fact" /> diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 2228f002..cb8f29a0 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -110,10 +110,8 @@ function flashOnce(key: string, fresh: boolean): boolean { interface Flag { key: string; - /** 'warn' = act on it (amber); 'note' = a neutral fact (muted); - * 'brand' = a standing state YOU chose (claimed, snoozed) — brand is the - * yours color, and a choice you made never hides behind a hover */ - tone: 'warn' | 'note' | 'brand'; + /** 'warn' = act on it (amber); 'note' = a neutral fact (muted) */ + tone: 'warn' | 'note'; label: string; detail: ReactNode; } @@ -135,28 +133,10 @@ function rowFlags( pull: DerivedPull, showIterating: boolean, depth: number, - orphanParent: ParentRef | null, - claimedByMe = false, - snoozedNow = false + orphanParent: ParentRef | null ): Flag[] { const p = pull; const flags: Flag[] = []; - if (claimedByMe) - flags.push({ - key: 'claimed', - tone: 'brand', - label: 'claimed', - detail: - 'You claimed this review — GitHub and the board both show you’re on it. Hover the row for Release.', - }); - if (snoozedNow) - flags.push({ - key: 'snoozed', - tone: 'brand', - label: 'snoozed', - detail: - 'Off your Review lens until tomorrow or its next change. Hover the row for Unsnooze.', - }); if (p.conflict && p.status !== 'unmergeable') flags.push({ key: 'conflicts', @@ -256,13 +236,7 @@ function RowDetails({ flags }: { flags: Flag[] }) { {flags.map(f => ( {f.label} @@ -275,14 +249,7 @@ function RowDetails({ flags }: { flags: Flag[] }) { {f.label} {f.detail} @@ -329,20 +296,17 @@ function useRowActions(pull: DerivedPull) { } /** - * The row's workflow verbs — Claim and Snooze, the board's two first-class - * gestures. They own no geometry: the data rail's right edge is the - * board's strongest column (the age numeral caps it), so the verbs FLOAT - * just left of the rail, fading in on row hover/focus (opacity only — - * the board's one reveal mechanism; motion stays reserved for state - * changes) on a muted backdrop that keeps them legible over the meta - * text beneath. Nothing is reserved, nothing ever shifts, every fact - * column stays aligned. Pointer-events follow the fade so the invisible - * words can never steal a click. Claim and Release wear brand (the - * invitation and its undo); Snooze, ink. A claim you HOLD is a - * commitment and commitments never hide: its standing mark is the brand - * hand riding with the CR pips (a claim is literally a pending review - * request). Touch has no hover, so there the verbs live in the kebab - * alone, labeled. Snooze renders only where it acts (Review). + * The row's two workflow buttons — Snooze and Claim — floating left of the + * data rail (zero standing geometry; the right edge stays the facts'). + * Each is its own chip with one rule: a button STANDS when it records a + * choice you made — a claimed row always shows "Release", a snoozed row + * always shows "Unsnooze", both in place with no hover — and reveals on + * row hover/focus when it merely OFFERS one ("Claim", "Snooze"). If both + * states are yours, both chips stand. Opacity-only reveal (motion stays + * reserved for state changes), pointer-events following it so a hidden + * offer can't steal a click. Touch has no hover: offers live in the kebab + * there, while standing exits remain visible. Snooze renders only where + * it acts (Review). */ function VerbDock({ pull, @@ -360,12 +324,14 @@ function VerbDock({ const snoozedNow = isSnoozed(pull.data, snoozed); const mine = claim?.login === me; const claimable = pull.data.user.login !== me && !mine; - if (!showSnooze && !claimable && !mine) return null; - const verb = - 'hit pressable rounded border-0 bg-transparent px-0.5 text-xs whitespace-nowrap'; + const showSnoozeChip = showSnooze || snoozedNow; + const showClaimChip = claimable || mine; + if (!showSnoozeChip && !showClaimChip) return null; + const chip = + 'hit pressable rounded-md border-0 bg-muted px-1.5 py-1 text-xs whitespace-nowrap'; return ( - - {showSnooze && ( + + {showSnoozeChip && ( )} - {mine ? ( - - ) : claimable ? ( - - ) : null} + {showClaimChip && + (mine ? ( + + ) : ( + + ))} ); } @@ -713,7 +680,6 @@ function RowImpl({ // both optional, so a lens that hasn't threaded them (yet) just sees null // and rowNote falls back to its base (pre-coordination) note const claim = claimFor(d); - const { snoozed: snoozedMap } = usePulldasher(); const turn = opts.turns?.get(key) ?? null; const poolSize = opts.pools?.get(d.repo)?.length ?? 0; // the viewer-relative note (model/actions.ts): never both-null for an open @@ -809,16 +775,7 @@ function RowImpl({ )} - + } rail={} diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index b14391bc..d1d39060 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -365,10 +365,6 @@ label:has(> input:not(:disabled)) { /* row flags: quiet colored labels, not badges — amber for act-on-it, muted ink for neutral facts. The cluster's hover popover carries meaning. */ - .flag-brand { - @apply text-[11px] font-medium; - color: var(--brand); - } .flag-warn { @apply text-[11px] font-medium; color: var(--warn); @@ -453,25 +449,25 @@ label:has(> input:not(:disabled)) { z-index: 1; } -/* the row's verbs (Claim / Snooze / Release) float left of the rail with - ZERO standing geometry — the rail's right edge is the board's strongest - column and stays the data's alone. They fade in on row hover or focus - (opacity only, the board's one reveal mechanism); pointer-events follow - the fade so invisible words never steal a click from the meta text they - float over. Touch has no hover: the kebab is the verbs' labeled home - there, so the float simply doesn't exist. */ -.pd-verbs { +/* the row's verb chips float left of the rail with ZERO standing geometry + — the rail's right edge is the board's strongest column and stays the + data's alone. A chip that only OFFERS (.pd-verb: Claim, Snooze) fades in + on row hover or focus, pointer-events following so a hidden offer never + steals a click; a chip that RECORDS your choice (Release, Unsnooze — no + .pd-verb) simply stands. Touch has no hover: offers live in the kebab + there, standing exits stay visible. */ +.pd-verb { opacity: 0; pointer-events: none; transition: opacity 150ms ease-out; } -.pd-row:hover .pd-verbs, -.pd-row:focus-within .pd-verbs { +.pd-row:hover .pd-verb, +.pd-row:focus-within .pd-verb { opacity: 1; pointer-events: auto; } @media (hover: none) { - .pd-verbs { + .pd-verb { display: none; } } From e568fb708c548128762263153fcc82f355a1d012 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 15:50:10 -0700 Subject: [PATCH 283/381] One fold grammar: every subsection is a collapsible eyebrow band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board spoke two subsection languages. Word groups ("RE-STAMP · 1") were quiet eyebrow bands the owner liked, but they didn't fold; the rest-of-the-board ledger, "their other PRs", and "you've stamped" were a different species entirely — 13px sentence-case
rows with a large chevron and a colored status dot. A reader had to learn both, and the two tiers side by side made the ledger read heavier than the primary lanes above it. Merge them: Fold IS the eyebrow now. One collapsible band — 11px caps, brand for a do-word, muted for everything that waits, "· count", a 12px chevron as the only disclosure affordance, no status dot (rows inside already carry their pips; a second color code on the band was redundant weight). Hovering the label opens its one-sentence gloss; clicking anywhere on the band toggles — including the label, whose popover trigger would otherwise swallow the click, so the trigger's onClick drives the
by hand. Word groups now fold too (open by default, choice remembered per fold id), which the owner asked for directly: collapse the REVIEW group once and the lane stays personal. What this unlocks per lens: - Review: the ledger buckets adopt the wait-word vocabulary the groups already speak (Blocked, Deploy hold, Conflicts, CI running, CI red, Drafts…), so the rest of the board reads as a closed table of contents in the same words as the lanes above it. Hints became hover glosses — locality over always-on chrome. - Team/People: "their other PRs" was one undifferentiated pile; it now word-groups under "The rest of their work", so re-stamps, blocks, and waits are legible per person — and the hand-made "you've stamped" fold dissolves, because a live stamp of yours already emerges from rowWord as the "stamped" group. - Ci: the per-check bands become folds (caps off — check names are case-sensitive identifiers), completing the pattern. - Classic/My work: inherit the collapsible groups through WordGroupRows. Truncation moves inside each group (cap per group, "+N more" in the fold) instead of one shared ration across groups — a collapsed group can't spend the open ones' budget. DESIGN.md: the four-tier header system is three tiers now, with the Fold contract written down. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 27 +++-- frontend-v2/src/components/Lane.tsx | 89 ++++++++++++++--- frontend-v2/src/components/WordGroups.tsx | 114 ++++++---------------- frontend-v2/src/views/Ci.tsx | 39 +++----- frontend-v2/src/views/MyWork.tsx | 5 +- frontend-v2/src/views/People.tsx | 50 +++++----- frontend-v2/src/views/Review.tsx | 60 +++++------- frontend-v2/src/views/Team.tsx | 30 +++--- 8 files changed, 212 insertions(+), 202 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index adb675c1..cb04d125 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -121,8 +121,8 @@ popover era dismissed a commitment-in-progress on any stray click.) says the ordering in one plain sentence (and is itself the hover-door to the full story — existing text becomes interactive, no info-icon chrome); each card's state popover carries a "why it's up next" line; and every -fold's hint says what lands in it. If a user has to ask why a card is -where it is, one of these layers is missing. +fold's label glosses what lands in it on hover. If a user has to ask why +a card is where it is, one of these layers is missing. ## Identity vs standing @@ -211,11 +211,26 @@ icon with a class bolted on. - **Counts are always quiet**: `tabular-nums` in the surrounding text color, never bold ink — a count is a fact, not an alert. (Fold counts once shipped bold and read as more urgent than the lane titles above them.) -- **One header system, four tiers**, each defined once: `BoardColumn` +- **One header system, three tiers**, each defined once: `BoardColumn` (collapsible column panel) > `GroupHeader` (sticky lane title) > - eyebrow labels (`eyebrowText` in WordGroups.tsx — word sub-headers and - band labels share the exported constant, never a hand-rolled copy) > - `Fold` summaries (disclosure rows, not titles). + `Fold` (the board's ONE subsection: a collapsible eyebrow band). + Every subdivision below a lane title is a Fold — word groups inside + "Waiting on you", the rest-of-the-board ledger, a person's other PRs, + Ci's per-check bands. The former fourth tier (non-folding word + sub-headers vs chevron-and-dot disclosure rows) merged into it: two + grouping languages on one board meant the reader learned both. + `eyebrowText` (exported from Lane.tsx) is the band's type treatment; + band labels outside a Fold ("Pick up next", "Check health") share the + constant, never a hand-rolled copy. +- **The Fold contract**: label speaks the row-word vocabulary (brand for + a do-word — the next step is yours; muted for everything that waits), + `· count` after it, a 12px chevron as the one disclosure affordance, + no status dot (the rows inside carry their own pips — a second color + code on the band was redundant weight). Clicking anywhere on the band + toggles, including the label; hovering the label opens its + one-sentence gloss. Primary lanes greet you with groups open; ledger + groups rest closed, so a quiet stack of bands reads as a table of + contents. An explicit open/closed choice is remembered per fold id. ## Settings & configuration diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index 6cb5ce1e..41fa8a3e 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode, type SyntheticEvent } from 'react'; +import { useRef, useState, type ReactNode, type SyntheticEvent } from 'react'; import { ChevronRight } from 'lucide-react'; import { pullKey } from '../format'; import type { DerivedPull } from '../model/status'; @@ -264,20 +264,43 @@ export function Truncated({ ); } -/** One folded line in "the rest of the board": count + hint, rows on demand. */ +/** + * The one eyebrow-label type treatment: every 11px uppercase in-list label on + * the board (Fold headers, Review's "Pick up next" band label, Ci's Check + * health label) shares this string so the tier can't drift apart one + * hand-rolled copy at a time. + */ +export const eyebrowText = 'text-[11px] font-semibold tracking-wide uppercase'; + +/** + * The board's one subsection: a collapsible eyebrow band. Word groups inside + * "Waiting on you", the rest-of-the-board ledger, a person's other PRs, Ci's + * per-check bands — every subdivision below a lane title is this component, + * so the whole board folds in a single grammar. The label speaks the same + * wait/do vocabulary the row words use: brand for your move, muted for + * everything that waits. Hovering the label opens its one-sentence gloss + * (locality — the legend stays optional reading); clicking anywhere on the + * band toggles the fold. + */ export function Fold({ - dot, count, label, - hint, + gloss, + tone = 'wait', + caps = true, id, defaultOpen = false, children, }: { - dot: string; count: number; label: string; - hint?: string; + /** one hover sentence explaining the label in place */ + gloss?: string; + /** 'do' = the next step is yours (brand); 'wait' = everything else */ + tone?: 'do' | 'wait'; + /** false for case-sensitive identifiers (CI check names) the eyebrow + * transform would mangle */ + caps?: boolean; /** stable identity: remembers this fold's open/closed choice across sessions */ id?: string; /** initial state when nothing is stored yet — e.g. auto-open the one fold @@ -286,6 +309,7 @@ export function Fold({ children: ReactNode; }) { if (!count) return null; + const detailsRef = useRef(null); const stored = foldOpenStore.useValue(); const explicit = id ? stored[id] : undefined; const open = explicit ?? defaultOpen; @@ -299,21 +323,64 @@ export function Fold({ if (foldOpenStore.get()[id] !== next) foldOpenStore.set({ ...foldOpenStore.get(), [id]: next }); }; + const labelInner = ( + <> + {label}{' '} + · {count} + + ); return (
- + - - {count} {label} - {hint && {hint}} + {gloss ? ( + ( + + )} + > + {/* the panel sits inside the uppercase eyebrow — undo the + treatment so the gloss reads as a normal sentence */} +

{gloss}

+
+ ) : ( + labelInner + )}
{children}
diff --git a/frontend-v2/src/components/WordGroups.tsx b/frontend-v2/src/components/WordGroups.tsx index a8bdd5a4..0725386a 100644 --- a/frontend-v2/src/components/WordGroups.tsx +++ b/frontend-v2/src/components/WordGroups.tsx @@ -1,19 +1,12 @@ -import type { ReactNode } from 'react'; import { DO_WORD_RANK, rowWord, WAIT_WORD_RANK, type RowWord } from '../model/actions'; import { groupIntoTree, type StackedPull } from '../model/stack'; import type { DerivedPull } from '../model/status'; import { pullKey } from '../format'; import { claimFor } from '../store'; -import { Truncated } from './Lane'; -import { Popover } from './Popover'; +import { Fold, Truncated } from './Lane'; import { Row, type RowOptions } from './Row'; -/** - * The one eyebrow-label type treatment: every 11px uppercase in-list label on - * the board (word sub-headers here, Review's "Pick up next" band label) shares - * this string so the tier can't drift apart one hand-rolled copy at a time. - */ -export const eyebrowText = 'text-[11px] font-semibold tracking-wide uppercase'; +export { eyebrowText } from './Lane'; /** * One plain sentence per group word, shown on hovering the header — the @@ -67,62 +60,6 @@ const WORD_GLOSS: Record = { waiting: 'Waiting, and the board can’t say on what.', }; -/** - * The quiet in-list label every word-grouped list splits on — the badge's - * replacement: instead of a per-card pill, the section itself says what's - * owed (brand) or why it waits (muted). Mirrors Classic's old SubHeader - * styling so the two lenses still read as one design. The word itself is a - * hover door to its one-sentence gloss (WORD_GLOSS above). - */ -export function WordSubHeader({ - word, - kind, - count, -}: { - word: string; - kind: RowWord['kind']; - count: number; -}) { - const gloss = WORD_GLOSS[word]; - const inner = ( - <> - {word}{' '} - · {count} - - ); - return ( -
- {gloss ? ( - ( - - )} - > - {/* the panel sits inside the uppercase eyebrow — undo the - treatment so the gloss reads as a normal sentence */} -

{gloss}

-
- ) : ( - inner - )} -
- ); -} - export interface WordGroup { word: string; kind: RowWord['kind']; @@ -167,39 +104,50 @@ export function groupNodesByWord(tree: StackedPull[], me: string): WordGroup[] { /** * The full word-grouped renderer: stack the pulls, bucket them by rowWord, - * and render each bucket as a header followed by its rows — exactly the rows - * FoldRows would produce, just split by section instead of one flat run. - * Headers don't count against `cap` (the truncation only rations rows), so - * the cap is lifted by the header count before handing it to Truncated. + * and render each bucket as its own Fold — the board's one subsection band, + * so every group can be collapsed and the choice is remembered. `cap` rations + * rows per group ("+N more" inside the fold); primary lanes greet you open + * (`foldDefaultOpen`), ledger uses pass false so the stack of bands reads as + * a quiet table of contents. */ export function WordGroupRows({ pulls, opts, id, cap, + foldDefaultOpen = true, }: { pulls: DerivedPull[]; opts: RowOptions; id?: string; cap: number; + foldDefaultOpen?: boolean; }) { const tree = groupIntoTree(pulls); const groups = groupNodesByWord(tree, opts.me); if (!groups.length) return null; - const children: ReactNode[] = groups.flatMap(g => [ - , - ...g.nodes.map(({ pull: p, depth }) => ( - - )), - ]); return ( - - {children} - + <> + {groups.map(g => { + const gid = id ? `${id}:${g.kind}:${g.word}` : undefined; + return ( + + + {g.nodes.map(({ pull: p, depth }) => ( + + ))} + + + ); + })} + ); } diff --git a/frontend-v2/src/views/Ci.tsx b/frontend-v2/src/views/Ci.tsx index e90b622a..9294232d 100644 --- a/frontend-v2/src/views/Ci.tsx +++ b/frontend-v2/src/views/Ci.tsx @@ -19,19 +19,6 @@ import { eyebrowText } from '../components/WordGroups'; * admits (same disclosure contract as Needs QA's CR overlap). */ -/** The per-check band header inside the failing lane — the word-group - * eyebrow's grammar with a check name where the verb would be. */ -function CheckHeader({ context, count }: { context: string; count: number }) { - return ( -
- {context}{' '} - · {count} -
- ); -} - /** * One check's health row: name, the board's 112px ruler carrying the red * failing share (and slate running share) of its runs, quiet counts, and the @@ -134,15 +121,23 @@ export function Ci({ pulls, opts }: { pulls: DerivedPull[]; opts: RowOptions }) count={byCheck.reduce((sum, g) => sum + g.pulls.length, 0)} opts={opts} > + {/* check names are case-sensitive identifiers, so the + fold skips the eyebrow's uppercase transform */} {byCheck.map(g => ( -
- - + + {g.pulls.map(p => ( ))} -
+ ))} @@ -174,10 +169,9 @@ export function Ci({ pulls, opts }: { pulls: DerivedPull[]; opts: RowOptions }) {green.length > 0 && ( @@ -186,10 +180,9 @@ export function Ci({ pulls, opts }: { pulls: DerivedPull[]; opts: RowOptions }) )} {noChecks.length > 0 && ( diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index 484083c7..452b184a 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -71,10 +71,9 @@ export function MyWork({ {shipped.length > 0 && ( (); - for (const p of allPulls.filter(x => !parked(x) && !authorOwnsIt(x))) for (const u of p.recrBy) owes.set(u, [...(owes.get(u) ?? []), p]); + for (const p of allPulls.filter(x => !parked(x) && !authorOwnsIt(x))) + for (const u of p.recrBy) owes.set(u, [...(owes.get(u) ?? []), p]); // starred people lead the directory; muted ones drop out entirely unless // they're the person an explicit pick (a URL or a click) already landed on @@ -61,7 +63,12 @@ export function People({ const selectedTeam = team && teams.some(t => t.team === team) ? team : null; const selectedPerson = selectedTeam ? null : (person ?? defaultPerson); if (!selectedPerson && !selectedTeam) { - return ; + return ( + + ); } const members = selectedTeam @@ -226,9 +233,9 @@ export function People({ sub={

- Lightest first, so a short break fits a review. A PR that needs just one - more approval jumps up (yours would finish it), and PRs move up as they - wait. PRs the author is still actively pushing to sink to the bottom. + Lightest first, so a short break fits a review. A PR that needs just one more + approval jumps up (yours would finish it), and PRs move up as they wait. PRs + the author is still actively pushing to sink to the bottom.

} @@ -236,30 +243,23 @@ export function People({ cap={8} opts={opts} /> + {/* everything not in the queue, split by the same words the Review + lens groups on — your live stamp emerges as the "stamped" group + instead of needing its own hand-made fold */} {(rest.length > 0 || owed.length > 0 || mine.length > 0) && ( - - - - - + - - + cap={laneShown(30, opts)} + foldDefaultOpen={false} + /> {selectedPerson && ( diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index fdab10c9..7215ddae 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -16,7 +16,7 @@ import { dealRank } from '../model/deal'; import { useSettings } from '../settings'; import { claimFor, clearSnoozes, isFresh, isSnoozed, usePulldasher } from '../store'; import type { PullData } from '../types'; -import { EmptyState, QuietButton, STATUS_DOT, STATUS_LABEL } from '../components/bits'; +import { EmptyState, QuietButton } from '../components/bits'; import { Fold, FoldRows, Lane, laneShown, RestGroup, SubDoor, Truncated } from '../components/Lane'; import { RegionHint } from '../components/RegionHint'; import type { RowOptions } from '../components/Row'; @@ -506,85 +506,76 @@ export function Review({ {(restTotal > 0 || napping.length > 0) && ( @@ -593,19 +584,17 @@ export function Review({ what's left here isn't up for review (merge-ready, in CI, or draft), so it stays folded and never auto-opens */} @@ -620,10 +609,9 @@ export function Review({ can always see what's hidden. Review-lens only: a snooze quiets this lens's daily loop, nothing else. */}
diff --git a/frontend-v2/src/views/Team.tsx b/frontend-v2/src/views/Team.tsx index b6e81cfe..1ecfd4c1 100644 --- a/frontend-v2/src/views/Team.tsx +++ b/frontend-v2/src/views/Team.tsx @@ -6,7 +6,8 @@ import { crSort } from '../model/sort'; import { teamBuckets } from '../model/team'; import { useSettings } from '../settings'; import { Avatar, EmptyState } from '../components/bits'; -import { Fold, FoldRows, Lane, RestGroup, SubDoor } from '../components/Lane'; +import { Lane, laneShown, RestGroup, SubDoor } from '../components/Lane'; +import { WordGroupRows } from '../components/WordGroups'; import { Popover } from '../components/Popover'; import type { RowOptions } from '../components/Row'; import { TeamPicker } from '../components/TeamPicker'; @@ -47,7 +48,8 @@ export function Team({ }, [allPulls]); const owes = useMemo(() => { const m = new Map(); - for (const p of allPulls.filter(x => !parked(x) && !authorOwnsIt(x))) for (const u of p.recrBy) m.set(u, [...(m.get(u) ?? []), p]); + for (const p of allPulls.filter(x => !parked(x) && !authorOwnsIt(x))) + for (const u of p.recrBy) m.set(u, [...(m.get(u) ?? []), p]); return m; }, [allPulls]); @@ -171,20 +173,18 @@ export function Team({ cap={9} opts={opts} /> + {/* everything not in the queue, split by the same words the Review + lens groups on — "stamped" (yours, in flight) emerges from the + vocabulary instead of needing its own hand-made fold */} {(stamped.length > 0 || rest.length > 0) && ( - - - - - - - + + )} From 00bf8146781914fe0b0162d3fabb4f3692188e30 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 15:58:52 -0700 Subject: [PATCH 284/381] Merge the People tab into Team: one tab, yours pinned first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit People and Team were the same board with different pickers: both split a member list through the reviewable / stamped / rest rules (People inlined them, Team called the unit-tested teamBuckets), both rendered the same queue and rest-group below. Two nav tabs whose bodies agree is one tab — and People made a weak top-level lens anyway: the daily loop never starts at a directory, and its cold-open default (whoever has the most PRs) was a board nobody asked for. What People uniquely carried survives, inside Team: - The owed re-stamps roll-up: every directory chip still wears the amber pip and count, the only at-a-glance "who is sitting on stale approvals" on the board. - Person as destination: clicking any avatar anywhere still lands on that person's page, now under the Team tab (#lens=team&person=x). - GitHub-team boards, via the config team chips. The merged layout answers "what is this tab for" in its own geometry: people, starting with yours. Row one is your picked team, the "Your team" aggregate chip (the tab's home board, active by default) leading its member chips and the Edit picker; behind it the directory: config teams, then everyone else, starred first, your members deduped out. An explicit pick earns the summary card; the home board's summary is the member strip itself. The build-your-team invitation only appears when you have no team and nothing picked, with the directory still browsable above it. Everything routes through teamBuckets now — a person is a one-member team — so People's duplicate inline bucket logic is gone and the live-vs-stale stamp doctrine keeps its single tested home. Legacy #lens=people URLs rewrite to team (person=/team= params carried along, the board→classic precedent), and old saved filters describe themselves as Team. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 26 +- frontend-v2/src/components/Settings.tsx | 2 - frontend-v2/src/model/savedFilters.ts | 3 +- frontend-v2/src/views/People.tsx | 272 ------------------- frontend-v2/src/views/Team.tsx | 339 +++++++++++++++++------- 5 files changed, 262 insertions(+), 380 deletions(-) delete mode 100644 frontend-v2/src/views/People.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 198a8e36..55fde98f 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -42,16 +42,15 @@ import { SavedFiltersInput, SavedFiltersMenu } from './components/SavedFiltersPa import type { RowOptions } from './components/Row'; import { Review } from './views/Review'; import { MyWork } from './views/MyWork'; -import { People } from './views/People'; import { Team } from './views/Team'; import { Classic } from './views/Classic'; import { Ci } from './views/Ci'; import { Stats } from './views/Stats'; import { Settings } from './components/Settings'; -type Lens = 'review' | 'mine' | 'team' | 'people' | 'classic' | 'ci' | 'stats'; +type Lens = 'review' | 'mine' | 'team' | 'classic' | 'ci' | 'stats'; -const LENSES: Lens[] = ['review', 'mine', 'team', 'people', 'classic', 'ci', 'stats']; +const LENSES: Lens[] = ['review', 'mine', 'team', 'classic', 'ci', 'stats']; /** every actionState bucket, for validating the `state=` hash param against */ const ACTION_STATE_KEYS: ActionStateKey[] = [ @@ -107,6 +106,9 @@ function readHash(): HashState { // the Board lens merged into Classic (same columns, real justification); // old #lens=board links keep working if ((lens as string) === 'board') lens = 'classic'; + // People merged into Team (same board, different picker); old links keep + // their person=/team= params, so they land on the same page they named + if ((lens as string) === 'people') lens = 'team'; // a bare URL (no lens param) opens the user's configured default view const fallback = defaultLensFallback(); return { @@ -672,7 +674,7 @@ export function App() { const onPerson = useCallback((login: string) => { setPerson(login); setTeam(null); - setLens('people'); + setLens('team'); }, []); // a row's weight chip toggles that bucket in the session Weight filter — // the same array WeightFilter's own checkboxes drive @@ -930,7 +932,6 @@ export function App() { {tab('review', 'Review')} {tab('mine', 'My work', mineCount)} {tab('team', 'Team')} - {tab('people', 'People')} {tab('classic', 'Classic')} {tab('ci', 'CI')} {tab('stats', 'Stats')} @@ -1062,16 +1063,6 @@ export function App() { )} {initialized && lens === 'team' && ( !isBot(p))} - me={me} - opts={rowOpts} - onPerson={onPerson} - extraBots={extraBots} - /> - )} - {initialized && lens === 'people' && ( - !isBot(p))} teams={allTeams} @@ -1085,7 +1076,12 @@ export function App() { setTeam(name); setPerson(null); }} + onHome={() => { + setPerson(null); + setTeam(null); + }} opts={rowOpts} + extraBots={extraBots} /> )} {initialized && lens === 'classic' && ( diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index a84bc5d2..ce9c7c20 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -19,7 +19,6 @@ const LENS_OPTIONS: [string, string][] = [ ['review', 'Review'], ['mine', 'My work'], ['team', 'Team'], - ['people', 'People'], ['classic', 'Classic'], ['ci', 'CI'], ['stats', 'Stats'], @@ -31,7 +30,6 @@ const LANE_CAP_LENS_OPTIONS: [string, string][] = [ ['review', 'Review'], ['mine', 'My work'], ['team', 'Team'], - ['people', 'People'], ['classic', 'Classic'], ['ci', 'CI'], ]; diff --git a/frontend-v2/src/model/savedFilters.ts b/frontend-v2/src/model/savedFilters.ts index a08eea3d..2d4ce24f 100644 --- a/frontend-v2/src/model/savedFilters.ts +++ b/frontend-v2/src/model/savedFilters.ts @@ -77,7 +77,8 @@ const LENS_LABEL: Record = { review: 'Review', mine: 'My work', team: 'Team', - people: 'People', + // legacy: the People tab merged into Team; old saved hashes still say it + people: 'Team', classic: 'Classic', stats: 'Stats', }; diff --git a/frontend-v2/src/views/People.tsx b/frontend-v2/src/views/People.tsx deleted file mode 100644 index 086b096a..00000000 --- a/frontend-v2/src/views/People.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useState } from 'react'; -import { authorOwnsIt, parked } from '../model/actions'; -import { STATUS_ORDER, type DerivedPull } from '../model/status'; -import { useSettings } from '../settings'; -import type { Team } from '../types'; -import { Avatar, EmptyState } from '../components/bits'; -import { Fold, FoldRows, Lane, laneShown, RestGroup, SubDoor } from '../components/Lane'; -import { WordGroupRows } from '../components/WordGroups'; -import type { RowOptions } from '../components/Row'; -import { crSort } from '../model/sort'; - -/** - * The directory: one tab, pick a person OR a team, same board below. - * (Teams used to be its own tab — structurally a clone of this view minus - * the person-only data, so the two merged.) - */ -export function People({ - pulls, - allPulls, - teams, - person, - team, - onPerson, - onTeam, - opts, -}: { - /** scoped pool (what the lanes show) */ - pulls: DerivedPull[]; - /** unscoped pool (for the picker counts and owed re-stamps) */ - allPulls: DerivedPull[]; - teams: Team[]; - person: string | null; - team: string | null; - onPerson: (login: string) => void; - onTeam: (team: string) => void; - opts: RowOptions; -}) { - const [allPeople, setAllPeople] = useState(false); - const { starredPeople, mutedPeople } = useSettings(); - const starredSet = new Set(starredPeople); - const mutedSet = new Set(mutedPeople); - const counts = new Map(); - for (const p of allPulls) - counts.set(p.data.user.login, (counts.get(p.data.user.login) ?? 0) + 1); - const owes = new Map(); - for (const p of allPulls.filter(x => !parked(x) && !authorOwnsIt(x))) - for (const u of p.recrBy) owes.set(u, [...(owes.get(u) ?? []), p]); - - // starred people lead the directory; muted ones drop out entirely unless - // they're the person an explicit pick (a URL or a click) already landed on - const logins = [...new Set([...counts.keys(), ...owes.keys()])] - .filter(l => !mutedSet.has(l) || l === person) - .sort( - (a, b) => - Number(starredSet.has(b)) - Number(starredSet.has(a)) || - (counts.get(b) ?? 0) - (counts.get(a) ?? 0) - ); - - // an explicit pick always wins, even with zero open PRs — silently - // showing someone else's board mid-conversation is worse than an empty one. - // The default skips yourself: you can't review your own PRs. - const defaultPerson = logins.find(l => l !== opts.me) ?? logins[0]; - const selectedTeam = team && teams.some(t => t.team === team) ? team : null; - const selectedPerson = selectedTeam ? null : (person ?? defaultPerson); - if (!selectedPerson && !selectedTeam) { - return ( - - ); - } - - const members = selectedTeam - ? (teams.find(t => t.team === selectedTeam)?.members ?? []) - : [selectedPerson as string]; - const isSubject = (p: DerivedPull) => members.includes(p.data.user.login); - - const theirs = pulls.filter(isSubject); - const theirsUnscoped = allPulls.filter(isSubject); - const owed = selectedPerson ? (owes.get(selectedPerson) ?? []) : []; - // Live vs stale stamp split (same rule as teamBuckets): a stale stamp of - // yours (recrBy) leaves the PR at 0-of-1, still reviewable by you or anyone - // else, so it stays in `reviewable`. Only a live stamp (crBy) is "stamped, - // waiting on another reviewer". - const reviewable = crSort( - theirs.filter( - p => - ['needs_cr', 'needs_recr'].includes(p.status) && - p.data.user.login !== opts.me && - !p.crBy.includes(opts.me) - ) - ); - const mine = theirs.filter( - p => - ['needs_cr', 'needs_recr'].includes(p.status) && - p.data.user.login !== opts.me && - p.crBy.includes(opts.me) - ); - const inLane = new Set([...reviewable, ...mine]); - const rest = theirs - .filter(p => !inLane.has(p)) - .sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status)); - const memberTeam = selectedPerson - ? teams.find(t => t.members.includes(selectedPerson))?.team - : null; - const shipping = theirs.filter(p => ['ready', 'needs_qa'].includes(p.status)).length; - const scopeHides = theirsUnscoped.length - theirs.length; - - const chip = (key: string, label: React.ReactNode, active: boolean, onPick: () => void) => ( - - ); - - const shownLogins = allPeople ? logins : logins.slice(0, 24); - - return ( - <> - {teams.length > 0 && ( -
- {teams.map(t => - chip( - `team:${t.team}`, - <> - {t.team} - - {allPulls.filter(p => t.members.includes(p.data.user.login)).length} - - , - t.team === selectedTeam, - () => onTeam(t.team) - ) - )} -
- )} -
- {shownLogins.map(login => - chip( - login, - <> - - {login} - - {counts.get(login) ?? 0} - - {/* the lead roll-up: who owes re-stamps, without clicking - through 30 people */} - {(owes.get(login)?.length ?? 0) > 0 && ( - - - {owes.get(login)!.length} - - )} - , - login === selectedPerson, - () => onPerson(login) - ) - )} - {!allPeople && logins.length > 24 && ( - - )} -
- -
- {selectedPerson ? ( - - ) : ( - - {members.slice(0, 6).map(m => ( - - ))} - - )} - - - {selectedPerson ?? selectedTeam} - -
- - {memberTeam ? `${memberTeam} · ` : ''} - {selectedTeam ? `${members.length} members · ` : ''} - {theirs.length === 0 - ? 'nothing open right now' - : `${theirs.length} open ${theirs.length === 1 ? 'PR' : 'PRs'}`} - {scopeHides > 0 && ( - · filters hide {scopeHides} more - )} - -
- - - {reviewable.length} - you can review - - - {shipping} - ready or in QA - - {selectedPerson && ( - - {owed.length} - {owed.length === 1 ? 're-stamp owed' : 're-stamps owed'} - - )} - -
- - -

- Lightest first, so a short break fits a review. A PR that needs just one more - approval jumps up (yours would finish it), and PRs move up as they wait. PRs - the author is still actively pushing to sink to the bottom. -

- - } - pulls={reviewable} - cap={8} - opts={opts} - /> - {/* everything not in the queue, split by the same words the Review - lens groups on — your live stamp emerges as the "stamped" group - instead of needing its own hand-made fold */} - {(rest.length > 0 || owed.length > 0 || mine.length > 0) && ( - - - {selectedPerson && ( - - - - )} - - )} - - ); -} diff --git a/frontend-v2/src/views/Team.tsx b/frontend-v2/src/views/Team.tsx index 1ecfd4c1..fb072f7a 100644 --- a/frontend-v2/src/views/Team.tsx +++ b/frontend-v2/src/views/Team.tsx @@ -1,47 +1,64 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { authorOwnsIt, parked } from '../model/actions'; import type { DerivedPull } from '../model/status'; import { matchesRegion } from '../model/regions'; import { crSort } from '../model/sort'; import { teamBuckets } from '../model/team'; import { useSettings } from '../settings'; +import type { Team as TeamGroup } from '../types'; import { Avatar, EmptyState } from '../components/bits'; -import { Lane, laneShown, RestGroup, SubDoor } from '../components/Lane'; -import { WordGroupRows } from '../components/WordGroups'; +import { Fold, FoldRows, Lane, laneShown, RestGroup, SubDoor } from '../components/Lane'; import { Popover } from '../components/Popover'; import type { RowOptions } from '../components/Row'; import { TeamPicker } from '../components/TeamPicker'; +import { WordGroupRows } from '../components/WordGroups'; /** - * The team lens: your own pick of teammates (Settings → Your team), not a - * GitHub team. Empty state onboards straight into the picker; once you've - * picked people it's a mini review board scoped to just them — the same - * reviewable/stamped/rest split People.tsx uses for one person, generalized - * to the whole list (see model/team.ts). + * The people tab (named Team): every board keyed by who wrote the work, + * starting with yours. Your picked team is the pinned first row and its + * aggregate board is the tab's home; behind it sits the whole directory — + * config.json teams and every author on the board — so any avatar click + * anywhere lands here on that person's page. One body serves all three + * selections (your team / a person / a GitHub team) through the same + * teamBuckets split, which is why the former separate People tab merged in: + * it was this view with a different picker. */ export function Team({ pulls, allPulls, - me, - opts, + teams, + person, + team, onPerson, + onTeam, + onHome, + opts, extraBots, }: { /** scoped pool (what the lanes show) */ pulls: DerivedPull[]; - /** unscoped pool (for the member strip's counts and the scope-hides note) */ + /** unscoped pool (for chip counts and owed re-stamps — filters must not + * make the directory lie about someone's real load) */ allPulls: DerivedPull[]; - me: string; - opts: RowOptions; + teams: TeamGroup[]; + person: string | null; + team: string | null; onPerson: (login: string) => void; + onTeam: (team: string) => void; + /** clear any selection: back to your team's aggregate board */ + onHome: () => void; + opts: RowOptions; extraBots?: ReadonlySet; }) { - const { myTeam, codeRegions } = useSettings(); + const me = opts.me; + const [allPeople, setAllPeople] = useState(false); + const { myTeam, codeRegions, starredPeople, mutedPeople } = useSettings(); + const starredSet = new Set(starredPeople); + const mutedSet = new Set(mutedPeople); - // authored/owed counts read the UNSCOPED pool, same as People.tsx — a - // narrowed scope shouldn't make the member strip lie about the team's - // real backlog. - const authored = useMemo(() => { + // authored/owed counts read the UNSCOPED pool: a narrowed scope shouldn't + // change what a chip says about a person's real backlog + const counts = useMemo(() => { const m = new Map(); for (const p of allPulls) m.set(p.data.user.login, (m.get(p.data.user.login) ?? 0) + 1); return m; @@ -53,92 +70,224 @@ export function Team({ return m; }, [allPulls]); - const theirsUnscoped = useMemo( - () => allPulls.filter(p => myTeam.includes(p.data.user.login)), - [allPulls, myTeam] - ); - const theirs = useMemo( - () => pulls.filter(p => myTeam.includes(p.data.user.login)), - [pulls, myTeam] - ); + // ---- selection: an explicit pick from the hash, else your team's board ---- + const explicitTeam = team && teams.some(t => t.team === team) ? team : null; + const selectedPerson = explicitTeam ? null : person; + // home = the tab with nothing picked; with a team configured that IS the + // "Your team" board, without one it's the build-your-team invitation + const home = !explicitTeam && !selectedPerson; + const selectedTeam = explicitTeam ?? (home && myTeam.length > 0 ? 'Your team' : null); + + const members = selectedPerson + ? [selectedPerson] + : selectedTeam + ? (teams.find(t => t.team === selectedTeam)?.members ?? []) + : []; + const isSubject = (p: DerivedPull) => members.includes(p.data.user.login); + const theirs = pulls.filter(isSubject); + const theirsUnscoped = allPulls.filter(isSubject); const scopeHides = theirsUnscoped.length - theirs.length; const { reviewable, stamped, rest } = useMemo( - () => teamBuckets(pulls, myTeam, me), - [pulls, myTeam, me] + () => teamBuckets(pulls, members, me), + // members is rebuilt per render; its join is the stable identity + [pulls, members.join(','), me] ); - const regionMatches = crSort(reviewable.filter(p => matchesRegion(p, codeRegions))); + const regionMatches = home ? crSort(reviewable.filter(p => matchesRegion(p, codeRegions))) : []; + const owed = selectedPerson ? (owes.get(selectedPerson) ?? []) : []; + const memberTeam = selectedPerson + ? teams.find(t => t.team !== 'Your team' && t.members.includes(selectedPerson))?.team + : null; + const shipping = theirs.filter(p => ['ready', 'needs_qa'].includes(p.status)).length; - if (myTeam.length === 0) { - return ( -
-

Build your team

-

- Pick the people whose work you review, the Team view and the “Your team” filter - follow this list. -

-
- -
-
+ // the directory: starred people lead, muted ones drop out unless an + // explicit pick (a URL or a click) already landed on them; your team's + // members are pinned in their own row above, so they don't repeat here + const logins = [...new Set([...counts.keys(), ...owes.keys()])] + .filter(l => (!mutedSet.has(l) || l === selectedPerson) && !myTeam.includes(l)) + .sort( + (a, b) => + Number(starredSet.has(b)) - Number(starredSet.has(a)) || + (counts.get(b) ?? 0) - (counts.get(a) ?? 0) + ); + const shownLogins = allPeople ? logins : logins.slice(0, 24); + + const chip = (key: string, label: React.ReactNode, active: boolean, onPick: () => void) => ( + + ); + const owesMark = (login: string) => + (owes.get(login)?.length ?? 0) > 0 && ( + + + {owes.get(login)!.length} + + ); + const personChip = (login: string, active: boolean) => + chip( + login, + <> + + {login} + {counts.get(login) ?? 0} + {owesMark(login)} + , + active, + () => onPerson(login) ); - } return ( <> -
- {myTeam.map(login => ( + {/* yours, pinned: the aggregate board chip, each member, and the picker */} + {myTeam.length > 0 && ( +
+ {chip( + 'your-team', + <> + Your team + + {allPulls.filter(p => myTeam.includes(p.data.user.login)).length} + + , + selectedTeam === 'Your team', + onHome + )} + {myTeam.map(login => personChip(login, login === selectedPerson))} + ( + + )} + > + + +
+ )} + {/* the rest of the directory: config teams, then everyone else */} +
+ {teams + .filter(t => t.team !== 'Your team') + .map(t => + chip( + `team:${t.team}`, + <> + {t.team} + + {allPulls.filter(p => t.members.includes(p.data.user.login)).length} + + , + t.team === explicitTeam, + () => onTeam(t.team) + ) + )} + {shownLogins.map(login => personChip(login, login === selectedPerson))} + {!allPeople && logins.length > 24 && ( - ))} - ( - - )} - > - - + )}
- {scopeHides > 0 && ( -
filters hide {scopeHides} more
+ {home && myTeam.length === 0 && ( +
+

Build your team

+

+ Pick the people whose work you review; their combined board becomes this tab’s + home. Or click anyone above to see just their work. +

+
+ +
+
+ )} + + {/* an explicit pick earns the summary card; the home board's summary + is the member strip itself (counts and owed pips per person) */} + {(selectedPerson || explicitTeam) && ( +
+ {selectedPerson ? ( + + ) : ( + + {members.slice(0, 6).map(m => ( + + ))} + + )} + + + {selectedPerson ?? explicitTeam} + +
+ + {memberTeam ? `${memberTeam} · ` : ''} + {explicitTeam ? `${members.length} members · ` : ''} + {theirs.length === 0 + ? 'nothing open right now' + : `${theirs.length} open ${theirs.length === 1 ? 'PR' : 'PRs'}`} + {scopeHides > 0 && ( + · filters hide {scopeHides} more + )} + +
+ + + {reviewable.length} + you can review + + + {shipping} + ready or in QA + + {selectedPerson && ( + + {owed.length} + {owed.length === 1 ? 're-stamp owed' : 're-stamps owed'} + + )} + +
)} - {reviewable.length === 0 && stamped.length === 0 && rest.length === 0 && ( - + {selectedTeam === 'Your team' && scopeHides > 0 && ( +
filters hide {scopeHides} more
)} + {selectedTeam === 'Your team' && + reviewable.length === 0 && + stamped.length === 0 && + rest.length === 0 && ( + + )} {codeRegions.length > 0 && regionMatches.length > 0 && ( )}

@@ -174,9 +323,9 @@ export function Team({ opts={opts} /> {/* everything not in the queue, split by the same words the Review - lens groups on — "stamped" (yours, in flight) emerges from the - vocabulary instead of needing its own hand-made fold */} - {(stamped.length > 0 || rest.length > 0) && ( + lens groups on — a live stamp of yours emerges as the "stamped" + group instead of needing its own hand-made fold */} + {(stamped.length > 0 || rest.length > 0 || owed.length > 0) && ( + {selectedPerson && ( + + + + )} )} From acd46faa42bfbb67b0c632c2d387e30932e26f04 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 16:08:47 -0700 Subject: [PATCH 285/381] CI lens: lead with the Check health dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-check ledger (failing/running share on the fixed ruler, average run time) sat at the bottom of the lens, after every pull list — but it's the lens's answer to its own headline question, "how is CI doing right now": one glance says which check is hurting before any individual pull matters. Dashboards summarize first, then itemize; the pull lanes (your broken builds, failing by check, running) are the itemization and now follow it. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/views/Ci.tsx | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/frontend-v2/src/views/Ci.tsx b/frontend-v2/src/views/Ci.tsx index 9294232d..7c4ca040 100644 --- a/frontend-v2/src/views/Ci.tsx +++ b/frontend-v2/src/views/Ci.tsx @@ -105,6 +105,18 @@ export function Ci({ pulls, opts }: { pulls: DerivedPull[]; opts: RowOptions }) return ( <> + {/* the dashboard first: every check's failing/running share on the + fixed ruler — the lens's own summary card, before any pull list */} + {ledgers.length > 0 && ( +

+
Check health
+ + {ledgers.map(l => ( + + ))} + +
+ )} )} - {ledgers.length > 0 && ( -
-
Check health
- - {ledgers.map(l => ( - - ))} - -
- )} {(green.length > 0 || noChecks.length > 0) && ( {green.length > 0 && ( From b3f56f6a4357803906d4b0cb2726954e4796298b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 16:24:56 -0700 Subject: [PATCH 286/381] Hovering a PR title previews its description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deciding "can I act on this?" often needs one more layer than the rail shows: the description — the summary, the QA checklist, the linked issue. That layer lived a tab away on GitHub. Now the title itself is a door: hover the visible words and the description opens beside them, rendered as GitHub would render it (gfm, single-newline breaks, inert task-list checkboxes) and sanitized (DOMPurify; PR-template HTML comments drop out, every link opens a new tab). Embedding the real GitHub page was considered and is impossible: github.com sends X-Frame-Options: deny, so it refuses to render in any frame. Rendering the body we already carry (PullData.body has been on the wire all along) is both the possible and the lighter answer — no server change, no extra fetch. Decisions along the way: - The hover region is the visible title text, not the row. The title anchor's stretched hit layer covers the whole card, and a preview from anywhere on the row would be a popover storm — so Popover grows a hoverTriggerOnly option that scopes its hover handlers to the trigger element instead of the root. - No click-pin. Clicking a title means "open the PR"; pinning a preview behind the tab you just opened is noise. - No door where there's nothing behind it: an empty description gets a plain title. - marked + DOMPurify load as their own chunk on the first hover (~24 kB gz); board startup pays nothing. - .md-prose sets the quoted document a notch under board type; blockquotes render as muted insets, not side-stripes. The dummy board's flat placeholder bodies become three rotating realistic descriptions so the feature demos honestly. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 6 +- frontend-v2/package-lock.json | 563 +++++++++++++++++++++++ frontend-v2/package.json | 3 + frontend-v2/src/backend/dummy.ts | 12 +- frontend-v2/src/components/Card.tsx | 12 +- frontend-v2/src/components/ClosedRow.tsx | 8 +- frontend-v2/src/components/Popover.tsx | 12 +- frontend-v2/src/components/Row.tsx | 1 + frontend-v2/src/components/bits.tsx | 50 +- frontend-v2/src/markdown.test.ts | 37 ++ frontend-v2/src/markdown.ts | 32 ++ frontend-v2/src/styles.css | 109 +++++ 12 files changed, 838 insertions(+), 7 deletions(-) create mode 100644 frontend-v2/src/markdown.test.ts create mode 100644 frontend-v2/src/markdown.ts diff --git a/DESIGN.md b/DESIGN.md index cb04d125..052ca2ea 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -290,7 +290,11 @@ icon with a class bolted on. - **Every curated lane's sub-line is a door** (`SubDoor` in Lane.tsx): the visible sentence states the ordering, hovering it opens what lands in the lane and how it's ranked. Every group eyebrow glosses itself the - same way (WORD_GLOSS). A control that sits away from the thing it acts + same way (WORD_GLOSS). The PR title is a door too: hovering the + visible words previews the description, rendered and sanitized + (markdown.ts, lazy chunk) — the "can I act on this?" read without + leaving the board. Hover only, never click-pin: the title's click + already means "open the PR". An empty description gets no door. A control that sits away from the thing it acts on names that thing in its own label, so the connection survives the distance. The test for any new mark, header, or lane: a developer who has never opened the legend can decode it from the screen diff --git a/frontend-v2/package-lock.json b/frontend-v2/package-lock.json index 1df2c8ab..b1e9c032 100644 --- a/frontend-v2/package-lock.json +++ b/frontend-v2/package-lock.json @@ -8,7 +8,9 @@ "name": "pulldasher-frontend-v2", "version": "0.0.1", "dependencies": { + "dompurify": "^3.4.12", "lucide-react": "^1.25.0", + "marked": "^18.0.7", "react": "^18.3.1", "react-dom": "^18.3.1", "socket.io-client": "^4.8.3" @@ -18,12 +20,64 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", "tailwindcss": "^4.1.0", "typescript": "^5.7.2", "vite": "^6.0.0", "vitest": "^2.1.8" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -306,6 +360,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -748,6 +955,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1513,6 +1738,13 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1643,6 +1875,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.6", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", @@ -1742,6 +1984,20 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1749,6 +2005,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1766,6 +2036,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1786,6 +2063,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.392", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", @@ -1829,6 +2115,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1958,6 +2257,26 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1974,6 +2293,57 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2309,6 +2679,25 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.7", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.7.tgz", + "integrity": "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2344,6 +2733,19 @@ "node": ">=18" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -2410,6 +2812,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -2445,6 +2857,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -2490,6 +2912,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2568,6 +3003,13 @@ "dev": true, "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2650,6 +3092,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2664,6 +3152,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3866,6 +4364,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "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", @@ -3904,6 +4450,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", diff --git a/frontend-v2/package.json b/frontend-v2/package.json index ce66ec80..060c149a 100644 --- a/frontend-v2/package.json +++ b/frontend-v2/package.json @@ -12,7 +12,9 @@ "preview": "vite preview" }, "dependencies": { + "dompurify": "^3.4.12", "lucide-react": "^1.25.0", + "marked": "^18.0.7", "react": "^18.3.1", "react-dom": "^18.3.1", "socket.io-client": "^4.8.3" @@ -22,6 +24,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", "tailwindcss": "^4.1.0", "typescript": "^5.7.2", "vite": "^6.0.0", diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts index 5ec5589b..4f1b9e76 100644 --- a/frontend-v2/src/backend/dummy.ts +++ b/frontend-v2/src/backend/dummy.ts @@ -33,7 +33,17 @@ export async function loadDummy(): Promise { deletions: Math.round(SIZE_SPREAD[i % SIZE_SPREAD.length] * 0.3), changed_files: 1 + (i % 22), }; - const pulls = withSyntheticStacks(raw).map((p, i) => withSizes(redate(p, i), i)); + // the fixture's bodies are one flat placeholder line; rotate realistic + // markdown so the title's description preview demos what it renders + // (headings, checklists, code, links) + const BODIES = [ + 'closes #4821\n\n## Summary\nMoves the cart badge count out of the page render and onto the shared header stream, so cached pages stop showing a stale count.\n\n## QA\n- [x] badge updates after adding to cart\n- [ ] logged-out view shows no badge\n- [ ] works on the checkout pages', + 'The old query scanned `pull_signatures` per row; this batches it:\n\n```sql\nSELECT number, MAX(date) FROM pull_signatures GROUP BY number\n```\n\nCuts the dashboard load from ~4s to ~300ms on prod data. See [the profile](https://example.com/profile) for before/after.', + '## Why\nSupport keeps getting "my order vanished" tickets — the order list dropped rows with a null `shipped_at`.\n\n> Root cause: the join predicate treated NULL as false.\n\nOne-line fix plus a regression test.', + ]; + const withBody = (p: PullData, i: number): PullData => + p.body === 'pull request dummy body' ? { ...p, body: BODIES[i % BODIES.length] } : p; + const pulls = withSyntheticStacks(raw).map((p, i) => withBody(withSizes(redate(p, i), i), i)); return { repos: [{ name: 'iFixit/ifixit' }], // The fixture carries almost no closed/merged pulls and no diff sizes, so diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index af872125..cde695f8 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -77,6 +77,7 @@ export function CardShell({ repo, number, title, + body, onOpen, id, className = '', @@ -94,6 +95,8 @@ export function CardShell({ repo: string; number: number; title: string; + /** PR description for the title's hover preview */ + body?: string; onOpen?: () => void; /** stable DOM id (format.ts's rowDomId) — lets toasts scroll to and * flash the row it just claimed. */ @@ -127,7 +130,14 @@ export function CardShell({ stackStub?: boolean; }) { const titleLink = ( - + ); const connector = depth > 0 ? ( diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index 157491c0..03aa1231 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -42,7 +42,13 @@ export const ClosedRow = memo(function ClosedRow({ - + diff --git a/frontend-v2/src/components/Popover.tsx b/frontend-v2/src/components/Popover.tsx index 1d41983e..b05288e1 100644 --- a/frontend-v2/src/components/Popover.tsx +++ b/frontend-v2/src/components/Popover.tsx @@ -7,6 +7,10 @@ export interface PopoverTriggerProps { 'aria-haspopup': 'dialog'; 'aria-expanded': boolean; onClick: () => void; + /** present only with `hoverTriggerOnly`: the hover handlers move off the + * root and onto the trigger element itself */ + onMouseEnter?: () => void; + onMouseLeave?: () => void; } /** @@ -45,6 +49,7 @@ export function Popover({ children, side = 'left', hover = false, + hoverTriggerOnly = false, width = '', panelClass = '', rootClass = 'relative inline-block', @@ -57,6 +62,10 @@ export function Popover({ side?: 'left' | 'right'; /** preview on hover, pin on click (for informational panels) */ hover?: boolean; + /** scope the hover to the trigger element instead of the whole root — + * for triggers whose hit area is deliberately larger than their visible + * text (the stretched row-covering title link) */ + hoverTriggerOnly?: boolean; /** tailwind width class for the panel, e.g. 'w-[360px]' */ width?: string; /** extra panel classes: padding, overflow, max-height, text size */ @@ -99,12 +108,13 @@ export function Popover({ }, [pop.open, side]); return ( - + {trigger({ ref: pop.triggerRef, 'aria-haspopup': 'dialog', 'aria-expanded': pop.open, onClick: pop.toggle, + ...(hoverTriggerOnly ? pop.hoverProps : {}), })} {pop.open && pos && diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index cb8f29a0..bf7e6386 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -704,6 +704,7 @@ function RowImpl({ repo={d.repo} number={d.number} title={d.title} + body={d.body} onOpen={() => ackPull(key)} id={rowDomId(d)} compact={opts.compact} diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 34c78555..81e89664 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import type { ButtonHTMLAttributes, CSSProperties, @@ -897,12 +897,16 @@ export function PullTitleLink({ repo, number, title, + body, onOpen, stretch, }: { repo: string; number: number; title: string; + /** the PR description; when present, hovering the visible title previews + * it rendered — the "can I act on this?" read without leaving the board */ + body?: string; /** fired when the user opens the PR — the row's natural "seen" ack */ onOpen?: () => void; /** cover the whole row as one click target */ @@ -913,6 +917,9 @@ export function PullTitleLink({ // rather. Read non-reactively: the row that renders this already subscribes // to settings, so a toggle re-renders it and this picks up the new value. const newTab = getSettings().openPrsNewTab; + // no door where there's nothing behind it: an empty description gets no + // popover, so the hover promise is always kept + const preview = body?.trim() ? body : null; return ( - {title} + {preview ? ( + // hoverTriggerOnly scopes the hover door to the visible words: the + // anchor's stretched hit layer covers the whole row, and a preview + // that opened from anywhere on the card would be a popover storm + {title}} + > + + + ) : ( + title + )} ); } +/** The rendered PR description inside the title's hover preview. Markdown + * tooling (marked + DOMPurify) loads as its own chunk on the first hover — + * board startup never pays for it. */ +function DescriptionBody({ md }: { md: string }) { + const [html, setHtml] = useState(null); + useEffect(() => { + let live = true; + void import('../markdown').then(m => { + if (live) setHtml(m.renderMarkdown(md)); + }); + return () => { + live = false; + }; + }, [md]); + if (html == null) return
Loading…
; + // sanitized in renderMarkdown; the description is author-authored remote + // content and never lands in the DOM unsanitized + return
; +} + /** * The number is what you say out loud; the repo is ambient context. Split * them typographically so the identifier steps forward and the repo whispers. diff --git a/frontend-v2/src/markdown.test.ts b/frontend-v2/src/markdown.test.ts new file mode 100644 index 00000000..38fe9f9d --- /dev/null +++ b/frontend-v2/src/markdown.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { renderMarkdown } from './markdown'; + +describe('renderMarkdown — the description preview is safe and GitHub-flavored', () => { + it('renders basic markdown', () => { + const html = renderMarkdown('**bold** and _em_'); + expect(html).toContain('bold'); + expect(html).toContain('em'); + }); + + it('strips script and inline handlers (author input never runs)', () => { + const html = renderMarkdown('hi '); + expect(html).not.toContain('script'); + expect(html).not.toContain('onerror'); + }); + + it('drops the HTML comments PR templates leave behind', () => { + expect(renderMarkdown('\nreal text')).not.toContain('fill this in'); + }); + + it('opens every link in a new tab', () => { + const html = renderMarkdown('[a link](https://example.com)'); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noopener noreferrer"'); + }); + + it('keeps task-list checkboxes but inert', () => { + const html = renderMarkdown('- [x] done\n- [ ] todo'); + expect(html).toContain('type="checkbox"'); + expect(html).toContain('disabled'); + }); + + it('treats single newlines as breaks, the way GitHub renders PR bodies', () => { + expect(renderMarkdown('line one\nline two')).toContain('
'); + }); +}); diff --git a/frontend-v2/src/markdown.ts b/frontend-v2/src/markdown.ts new file mode 100644 index 00000000..b1e79602 --- /dev/null +++ b/frontend-v2/src/markdown.ts @@ -0,0 +1,32 @@ +import DOMPurify from 'dompurify'; +import { marked } from 'marked'; + +/** + * PR descriptions rendered for the title's hover preview. GitHub flavor: + * single newlines break (GitHub renders PR bodies that way), gfm tables and + * strikethrough on. Sanitized because a description is arbitrary author + * input landing in our DOM — DOMPurify also drops the HTML comments PR + * templates leave behind. Every link opens in a new tab: the reader is + * mid-hover on a board they haven't left yet. + * + * This module is imported dynamically by the preview panel (first hover), + * so marked/dompurify stay out of the initial bundle. + */ + +DOMPurify.addHook('afterSanitizeAttributes', node => { + if (node.tagName === 'A') { + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer'); + } + // task-list checkboxes survive (a QA checklist is exactly the "can I act + // on this" signal the preview exists for) but stay inert + if (node.tagName === 'INPUT') node.setAttribute('disabled', ''); +}); + +export function renderMarkdown(md: string): string { + const raw = marked.parse(md, { async: false, gfm: true, breaks: true }); + return DOMPurify.sanitize(raw, { + // a description is prose, not an app: no forms, no media players + FORBID_TAGS: ['style', 'form', 'button', 'iframe', 'video', 'audio'], + }); +} diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index d1d39060..8a8f3abe 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -770,3 +770,112 @@ input[type="checkbox"] { scale: 1; } } + +/* ---- .md-prose: the rendered PR description inside the title's hover + preview (markdown.ts sanitizes; this only sets the type). Sized a notch + under the board's own text so the quoted document reads as an inset. ---- */ +.md-prose { + color: var(--ink-2); + line-height: 1.5; + overflow-wrap: break-word; +} +.md-prose > :first-child { + margin-top: 0; +} +.md-prose > :last-child { + margin-bottom: 0; +} +.md-prose h1, +.md-prose h2, +.md-prose h3, +.md-prose h4, +.md-prose h5, +.md-prose h6 { + margin: 12px 0 4px; + font-size: 12px; + font-weight: 600; + color: var(--ink); +} +.md-prose h1 { + font-size: 14px; +} +.md-prose h2 { + font-size: 13px; +} +.md-prose p { + margin: 6px 0; +} +.md-prose ul, +.md-prose ol { + margin: 6px 0; + padding-left: 18px; +} +.md-prose ul { + list-style: disc; +} +.md-prose ol { + list-style: decimal; +} +.md-prose li { + margin: 2px 0; +} +/* gfm task-list items: the checkbox replaces the bullet */ +.md-prose li:has(> input[type='checkbox']) { + margin-left: -14px; + list-style: none; +} +.md-prose input[type='checkbox'] { + margin-right: 4px; + vertical-align: -1px; +} +.md-prose code { + border-radius: 4px; + background: var(--muted); + padding: 1px 4px; + font-size: 12px; +} +.md-prose pre { + margin: 6px 0; + border-radius: 8px; + background: var(--muted); + padding: 8px 10px; + overflow-x: auto; +} +.md-prose pre code { + background: none; + padding: 0; +} +.md-prose a { + color: var(--brand); +} +.md-prose a:hover { + text-decoration: underline; +} +.md-prose img { + max-width: 100%; + max-height: 260px; + border-radius: 8px; +} +.md-prose blockquote { + margin: 6px 0; + border-radius: 8px; + background: var(--muted); + padding: 6px 10px; +} +.md-prose hr { + margin: 10px 0; + border: 0; + border-top: 1px solid var(--secondary); +} +.md-prose table { + display: block; + margin: 6px 0; + border-collapse: collapse; + overflow-x: auto; + font-size: 12px; +} +.md-prose th, +.md-prose td { + border: 1px solid var(--secondary); + padding: 3px 8px; +} From 7d7d1dfb1d188397777234f555bebd9c1ff827a9 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 16:35:49 -0700 Subject: [PATCH 287/381] Raise the title's hover trigger above the row's hit layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The description preview never opened for a real pointer: the title anchor's stretched click layer (.pd-link::after, z 0) paints over the row's inline content, so the cursor hit-tested the layer and the trigger span inside the anchor never received mouseenter. The dummy verification passed because it dispatched synthetic mouseover events, which bypass hit-testing — a trap worth remembering: verify hover features with a real pointer. The fix is the house pattern the layer's own comment prescribes: genuinely interactive children sit above it via .pd-raise. The span stays inside the anchor, so clicking the words still opens the PR. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/bits.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 81e89664..77ab8a52 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -940,8 +940,16 @@ export function PullTitleLink({ width="w-[440px] max-w-[90vw]" panelClass="p-3 max-h-[420px] overflow-auto" // no click-pin: a click on the title means "open the PR", and - // pinning a preview behind the tab you just opened is noise - trigger={({ onClick: _pin, ...t }) => {title}} + // pinning a preview behind the tab you just opened is noise. + // pd-raise lifts the words above the anchor's stretched hit + // layer (z 0) — without it the pointer hit-tests the ::after, + // the span never sees mouseenter, and the door never opens; + // still inside the anchor, so a click navigates as before + trigger={({ onClick: _pin, ...t }) => ( + + {title} + + )} > From 046fadd122cd288650398d086fd9051b8d546071 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 17:02:36 -0700 Subject: [PATCH 288/381] CI lens: the check ledger IS the failing-by-check view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check health and "Failing, by check" were the same list twice — every check ranked worst-first up top, then the same checks again below with their pulls. Merged: each check is one band carrying its glance (the fixed-ruler failing/running share, "5 of 57 runs failing", average run time), and a failing check opens into the PRs it's failing, yours included. Healthy checks render the same band without a chevron — nothing to open, nothing pretending otherwise. All bands start collapsed and stay that way until the user chooses: this view's first audience is QA engineering asking "are our tests failing in certain ways?", and the glance row answers that; the pulls are drill-down. Fixing that default exposed a Fold bug: browsers fire `toggle` on a
that MOUNTS open, so every defaultOpen fold was writing itself into the open-state store on first paint — an explicit- choices-only store that was silently recording choices nobody made. onToggle now ignores any toggle matching the rendered state. Fold grows two small props for the band: `detail` (right-aligned content) and `showCount` (off when the detail says the count in words). Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Lane.tsx | 25 ++++- frontend-v2/src/views/Ci.tsx | 161 +++++++++++++++------------- 2 files changed, 105 insertions(+), 81 deletions(-) diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index 41fa8a3e..d7f3d806 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -288,6 +288,8 @@ export function Fold({ gloss, tone = 'wait', caps = true, + showCount = true, + detail, id, defaultOpen = false, children, @@ -301,6 +303,11 @@ export function Fold({ /** false for case-sensitive identifiers (CI check names) the eyebrow * transform would mangle */ caps?: boolean; + /** hide the "· N" when the `detail` slot already states the count in its + * own words (the CI ledger's "5 of 57 runs failing") */ + showCount?: boolean; + /** right-aligned band content — a ruler, counts in words, a time */ + detail?: ReactNode; /** stable identity: remembers this fold's open/closed choice across sessions */ id?: string; /** initial state when nothing is stored yet — e.g. auto-open the one fold @@ -314,19 +321,22 @@ export function Fold({ const explicit = id ? stored[id] : undefined; const open = explicit ?? defaultOpen; // native
owns its own open/closed state on click; we only need to - // hear about it so the store matches, not to drive every toggle ourselves — - // writing back only on an actual change keeps stored-value re-renders from - // re-triggering this handler + // hear about it so the store matches. The browser also fires `toggle` on + // MOUNT for a details that renders open, so only a toggle that DIFFERS + // from the rendered state (a real user flip) is recorded — otherwise every + // defaultOpen fold would write itself into the store on first paint and + // "collapsed unless you chose otherwise" could never hold const onToggle = (e: SyntheticEvent) => { if (!id) return; const next = e.currentTarget.open; + if (next === open) return; if (foldOpenStore.get()[id] !== next) foldOpenStore.set({ ...foldOpenStore.get(), [id]: next }); }; const labelInner = ( <> - {label}{' '} - · {count} + {label} + {showCount && · {count}} ); return ( @@ -381,6 +391,11 @@ export function Fold({ ) : ( labelInner )} + {detail && ( + + {detail} + + )}
{children}
diff --git a/frontend-v2/src/views/Ci.tsx b/frontend-v2/src/views/Ci.tsx index 7c4ca040..f4172e26 100644 --- a/frontend-v2/src/views/Ci.tsx +++ b/frontend-v2/src/views/Ci.tsx @@ -3,29 +3,29 @@ import { lastPushEpoch, type DerivedPull } from '../model/status'; import { checkLedgers, ciSecsWord, type CheckLedger } from '../model/ci'; import { pullKey } from '../format'; import { EmptyState } from '../components/bits'; -import { Fold, FoldRows, Lane, laneShown, RestGroup, Rows, Truncated } from '../components/Lane'; +import { Fold, FoldRows, Lane, laneShown, RestGroup, SubDoor, Truncated } from '../components/Lane'; import { Row, type RowOptions } from '../components/Row'; -import { eyebrowText } from '../components/WordGroups'; /** - * The CI lens: the board re-keyed by the machine reviewer. Where Review asks - * "what's my move," this asks "what's broken, by which check" — your own red - * builds first (fixing CI is always the author's move), then everyone else's - * failures grouped under the check that's failing them, then what's still - * running, then a per-check health ledger. The rest folds complete the - * partition (all-green and no-checks pulls), so every open pull is - * accounted for exactly once across the lanes and folds — except that a - * pull failing several checks sits under each of them, which the sub-line - * admits (same disclosure contract as Needs QA's CR overlap). + * The CI lens: the board re-keyed by the machine reviewer. It leads with the + * check ledger — every check on the board, worst first, each row carrying + * its health at a glance (failing/running share on the fixed ruler, counts + * in words, average run time), and each FAILING check opening into the PRs + * it's failing. That one section is the QA-engineering read: "are our tests + * failing in certain ways?" The dev read follows: your own red builds, then + * what's still running, then the rest folds (all-green, no-checks). A pull + * failing several checks sits under each of them, which the sub-line admits + * (same disclosure contract as Needs QA's CR overlap). */ /** - * One check's health row: name, the board's 112px ruler carrying the red - * failing share (and slate running share) of its runs, quiet counts, and the - * average run time. The ruler matches the weight strip's fixed extent so - * fractions compare truthfully down the list. + * One check's health, at a glance: the board's 112px ruler carrying the red + * failing share (and slate running share) of its runs, the counts in words, + * and the average run time. Lives in a Fold's band (failing checks, where it + * doubles as the accordion title) and in the chevron-less row a healthy + * check gets — the ruler's fixed extent keeps fractions comparable in both. */ -function HealthRow({ ledger }: { ledger: CheckLedger }) { +function LedgerGlance({ ledger }: { ledger: CheckLedger }) { const { context, failing, running, total, avgSecs } = ledger; const failPct = (failing.length / total) * 100; const runPct = (running / total) * 100; @@ -35,10 +35,7 @@ function HealthRow({ ledger }: { ledger: CheckLedger }) { ? `${running} of ${total} still running` : `all ${total} green`; return ( -
- - {context} - + <> )} - {word} - + {word} + {avgSecs != null ? `~${ciSecsWord(avgSecs)}` : '—'} -
+ ); } @@ -71,19 +68,6 @@ export function Ci({ pulls, opts }: { pulls: DerivedPull[]; opts: RowOptions }) ); const mineBroken = failing.filter(p => p.data.user.login === me); - // others' failures re-keyed by the check that's failing them, worst check - // first — a pull failing two checks sits under both (the sub-line admits - // it); yours are excluded here because they already lead the page - const byCheck = useMemo(() => { - const mine = new Set(failing.filter(p => p.data.user.login === me).map(p => pullKey(p.data))); - return ledgers - .map(l => ({ - context: l.context, - pulls: l.failing.filter(p => !mine.has(pullKey(p.data))), - })) - .filter(g => g.pulls.length > 0); - }, [ledgers, failing, me]); - const running = useMemo( () => withChecks @@ -105,55 +89,80 @@ export function Ci({ pulls, opts }: { pulls: DerivedPull[]; opts: RowOptions }) return ( <> - {/* the dashboard first: every check's failing/running share on the - fixed ruler — the lens's own summary card, before any pull list */} + {/* the ledger IS the dashboard: every check, worst first, health in + the band; a failing check opens into the PRs it's failing. All + collapsed until the user chooses otherwise (the choice is + remembered) — the glance is the point, the pulls are the detail. */} {ledgers.length > 0 && ( -
-
Check health
- - {ledgers.map(l => ( - - ))} - -
- )} - - {byCheck.length > 0 && ( -
- sum + g.pulls.length, 0)} - opts={opts} - > - {/* check names are case-sensitive identifiers, so the - fold skips the eyebrow's uppercase transform */} - {byCheck.map(g => ( + +

+ Each band: the share of the check’s runs failing (red) or still running + (slate), the counts in words, and the average run time. +

+

+ A failing check opens into every PR it’s failing, oldest first, yours + included. A PR failing two checks sits under both. +

+ + } + pulls={[]} + count={ledgers.length} + opts={opts} + > + {/* check names are case-sensitive identifiers, so the fold + skips the eyebrow's uppercase transform */} + {ledgers.map(l => + l.failing.length > 0 ? ( } + id={`ci:health:${l.context}`} > - - {g.pulls.map(p => ( + + {l.failing.map(p => ( ))} - ))} -
-
+ ) : ( + // a healthy check has nothing to open: same band, no + // chevron — the leading spacer keeps names in one column +
+ + + {l.context} + + + + +
+ ) + )} +
)} + Date: Wed, 22 Jul 2026 17:02:37 -0700 Subject: [PATCH 289/381] Team: the directory rests behind an Everyone door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirty stranger-chips stood above your own team's board and outweighed it. The directory (config teams + every author) now sits behind one quiet "Everyone · N" control at the row's far end — yours on the left, the door to the rest of the world on the right. The door opens itself exactly when hiding it would lie: no team configured yet (the directory is the only content), or the current pick lives outside your team (the chip explaining whose board you're on must stand). Your own toggle wins for the rest of the visit. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/views/Team.tsx | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/frontend-v2/src/views/Team.tsx b/frontend-v2/src/views/Team.tsx index fb072f7a..76bb19ac 100644 --- a/frontend-v2/src/views/Team.tsx +++ b/frontend-v2/src/views/Team.tsx @@ -52,6 +52,11 @@ export function Team({ }) { const me = opts.me; const [allPeople, setAllPeople] = useState(false); + // the directory rests behind a door: your team is the tab's home, and + // thirty stranger-chips standing above it outweighed the content. null = + // no explicit choice (the door follows the selection); true/false = the + // user's own toggle for this visit + const [directoryChoice, setDirectoryChoice] = useState(null); const { myTeam, codeRegions, starredPeople, mutedPeople } = useSettings(); const starredSet = new Set(starredPeople); const mutedSet = new Set(mutedPeople); @@ -112,6 +117,13 @@ export function Team({ ); const shownLogins = allPeople ? logins : logins.slice(0, 24); + // the door opens itself when it must: no team yet (the directory is the + // only content), or the current pick lives outside your team (hiding the + // chip that explains the board would orphan it) + const outsidePick = + !!explicitTeam || (!!selectedPerson && !myTeam.includes(selectedPerson)); + const directoryShown = myTeam.length === 0 || (directoryChoice ?? outsidePick); + const chip = (key: string, label: React.ReactNode, active: boolean, onPick: () => void) => (
)} - {/* the rest of the directory: config teams, then everyone else */} + {/* the rest of the directory: config teams, then everyone else — + behind the Everyone door unless it must stand (see above) */} + {directoryShown && (
{teams .filter(t => t.team !== 'Your team') @@ -217,6 +240,7 @@ export function Team({ )}
+ )} {home && myTeam.length === 0 && (
From 857ad0869b3211039f7600ff0355c376a4271e65 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 17:02:37 -0700 Subject: [PATCH 290/381] State popover: every fact links to its source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popover stated facts the wire could substantiate but didn't hand over: context at a glance means the door to the evidence, not a trip through GitHub's tabs. - Conflicts name the branch they fight ("conflicts with master") and link straight into GitHub's conflict editor (/conflicts). - Blocks say which kind (dev blocked / deploy blocked) — the comment permalink was already there via signatureUrl. - Each failing CI check links to its own run log (the status's target_url; the PR checks tab as fallback). - closes/connects render as issue links — the server has parsed these body tags all along (models/pull.js), the client types just never declared them. - branch shows its base ("into master"), and milestone, assignees, and the comment count/last-comment age surface when present. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/StatePopover.tsx | 66 +++++++++++++++++++-- frontend-v2/src/format.ts | 4 ++ frontend-v2/src/types.ts | 4 ++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/frontend-v2/src/components/StatePopover.tsx b/frontend-v2/src/components/StatePopover.tsx index d9ed6273..fb7872f5 100644 --- a/frontend-v2/src/components/StatePopover.tsx +++ b/frontend-v2/src/components/StatePopover.tsx @@ -1,9 +1,9 @@ import type { ReactNode } from 'react'; -import type { DerivedPull } from '../model/status'; +import { headStatuses, type DerivedPull } from '../model/status'; import type { Claim, RowNote } from '../model/actions'; import { rowNote } from '../model/actions'; import type { PullData } from '../types'; -import { ago, closedEpoch, epoch, githubUrl, signatureUrl } from '../format'; +import { ago, closedEpoch, epoch, githubUrl, issueUrl, signatureUrl } from '../format'; import { Avatar, ClosedBadge, STATUS_LABEL } from './bits'; import { Popover } from './Popover'; @@ -41,7 +41,7 @@ function feedbackSources(p: DerivedPull): FeedbackSource[] { .map(s => ({ key: `block-${s.data.comment_id}`, login: s.data.user.login, - stateWord: 'blocked', + stateWord: s.data.type === 'dev_block' ? 'dev blocked' : 'deploy blocked', atEpoch: epoch(s.data.created_at), url: signatureUrl(s), })); @@ -133,6 +133,23 @@ function FactsSection({ : pull.ci === 'success' ? 'green' : 'none required'; + // a failing check's name links straight to its run log (the status's + // target_url) — "which check, and show me" without a GitHub detour + const redLogs = new Map( + headStatuses(d) + .filter(s => s.data.state === 'failure' || s.data.state === 'error') + .map(s => [s.data.context, s.data.target_url]) + ); + const srcLink = (href: string, word: string) => ( + + {word} + + ); return (
{/* no "· updated X ago" here: it duplicated the "last commit X ago" @@ -146,7 +163,22 @@ function FactsSection({ need a trip to GitHub */}

branch {d.head.ref} + into {d.base.ref}

+ {/* the conflict named and actionable in place: which branch it + fights, and the door to GitHub's conflict editor */} + {pull.conflict && ( +

+ conflicts with {d.base.ref} ·{' '} + {srcLink(`${githubUrl(d.repo, d.number)}/conflicts`, 'resolve on GitHub →')} +

+ )} + {d.closes != null && ( +

closes {srcLink(issueUrl(d.repo, d.closes), `#${d.closes}`)}

+ )} + {d.connects != null && ( +

connects {srcLink(issueUrl(d.repo, d.connects), `#${d.connects}`)}

+ )}

CR {pull.crHave} of {crReq} {pull.crBy.length > 0 && <> · {pull.crBy.join(', ')}} @@ -164,10 +196,32 @@ function FactsSection({

CI: {ciWord} - {pull.ci === 'failing' && pull.ciFailing.length > 0 && ( - <> · {pull.ciFailing.join(', ')} - )} + {pull.ci === 'failing' && + pull.ciFailing.map(ctx => ( + + {' · '} + {srcLink( + redLogs.get(ctx) || `${githubUrl(d.repo, d.number)}/checks`, + ctx + )} + + ))}

+ {(d.status.comment_count ?? 0) > 0 && ( +

+ {d.status.comment_count} comment{d.status.comment_count === 1 ? '' : 's'} + {d.status.last_comment_at && <> · last {ago(epoch(d.status.last_comment_at))} ago} +

+ )} + {(d.assignees?.length ?? 0) > 0 &&

assigned to {d.assignees!.join(', ')}

} + {d.milestone.title && ( +

+ milestone {d.milestone.title} + {d.milestone.due_on && ( + · due {d.milestone.due_on.slice(0, 10)} + )} +

+ )} {/* a claim always trumps the rotation guess — same precedence as model/actions.ts's withCoordination */} {claim ? ( diff --git a/frontend-v2/src/format.ts b/frontend-v2/src/format.ts index e5ebf53e..55661336 100644 --- a/frontend-v2/src/format.ts +++ b/frontend-v2/src/format.ts @@ -33,6 +33,10 @@ export function githubUrl(repo: string, number: number) { return `https://github.com/${repo}/pull/${number}`; } +export function issueUrl(repo: string, number: number | string) { + return `https://github.com/${repo}/issues/${number}`; +} + /** * A permalink to the comment or review a stamp came from. The comment_id is * two different GitHub id spaces, so the anchor depends on source_type; a diff --git a/frontend-v2/src/types.ts b/frontend-v2/src/types.ts index cb90b18b..672c24b0 100644 --- a/frontend-v2/src/types.ts +++ b/frontend-v2/src/types.ts @@ -71,6 +71,10 @@ export interface PullData { additions: number | null; deletions: number | null; changed_files?: number | null; + /** issue numbers parsed from the description's closes/connects body tags + * (server-side, models/pull.js parseBody); string digits on the wire */ + closes?: string | number | null; + connects?: string | number | null; milestone: { title: string | null; due_on: string | null }; head: { ref: string; sha: string; repo: { owner: { login: string } } }; base: { ref: string }; From e169eee8d88eb4b8a7bd122c3324ee3dc79e8540 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 17:02:38 -0700 Subject: [PATCH 291/381] Reconnect and re-auth promptly after a tab sleeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Live updates lost … retrying in the background" was sticking after overnight sleeps because the retry could never succeed: - A failed /token fetch stayed CACHED: the promise rejected once and every later caller — including each reconnect — replayed that same rejection forever. Failures now evict themselves from the cache. - The auth handshake had one shot: on reconnect, a token failure set the error banner and stopped. The socket sat connected but unauthenticated, so the server never re-sent data. Authentication now retries with backoff (2s doubling to a 30s cap), reset by any connect/disconnect. - Nothing kicked the socket on wake: Chrome freezes background tabs and laptops sleep mid-backoff, and socket.io would then sit out the rest of its delay. The tab becoming visible, the window refocusing, or the network returning now reconnects immediately. - The one failure a client can't heal alone — an app session cookie that expired overnight — gets a guarded page reload (visible + online tabs only, at most once per 5 minutes): the auth gate re-runs the OAuth handshake, which is silent with a live GitHub session, and the board comes back signed in. /token redirects now read as failures instead of JSON parse errors, so that path is detected at all. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/backend/socket.ts | 72 +++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/frontend-v2/src/backend/socket.ts b/frontend-v2/src/backend/socket.ts index 6e5c7651..d5557442 100644 --- a/frontend-v2/src/backend/socket.ts +++ b/frontend-v2/src/backend/socket.ts @@ -28,29 +28,85 @@ function liveBackend(): Backend { token = token ?? fetch('/token').then(async r => { - if (!r.ok) throw new Error(`token fetch failed: ${r.status}`); + // an expired session redirects toward the OAuth flow instead of + // returning JSON — surface that as a failure, not a parse error + if (!r.ok || r.redirected) throw new Error(`token fetch failed: ${r.status}`); const t = (await r.json()) as TokenResponse; if (!t.socketToken) throw new Error('token response missing socketToken'); return t; }); + // a failed fetch must not poison the cache: the next caller retries + // instead of replaying tonight's rejection forever + token.catch(() => { + token = null; + }); return token; }; let onState: ((state: ConnectionState) => void) | null = null; + // Overnight, the app session cookie can expire while the tab sleeps; the + // socket then reconnects fine but /token fails and the board would sit on + // "retrying" forever. A visible, online tab whose token keeps failing gets + // ONE reload per 5 minutes: the page-level auth gate re-runs the OAuth + // handshake (silent with a live GitHub session) and comes back signed in. + const RELOAD_AT_KEY = 'pd2.authReloadAt'; + const maybeReloadForAuth = () => { + if (document.visibilityState !== 'visible' || !navigator.onLine) return; + const last = Number(sessionStorage.getItem(RELOAD_AT_KEY) || 0); + if (Date.now() - last < 5 * 60_000) return; + sessionStorage.setItem(RELOAD_AT_KEY, String(Date.now())); + location.reload(); + }; + + // The auth handshake retries on its own: a socket that reconnected after a + // sleep is useless until 'authenticate' lands, so a token failure backs + // off (2s → 30s cap) instead of giving up after one shot. A new connect or + // a disconnect always resets the loop. + let authRetry: ReturnType | null = null; + const clearAuthRetry = () => { + if (authRetry != null) clearTimeout(authRetry); + authRetry = null; + }; + const authenticate = (attempt = 0) => { + // Socket tokens are single-use and expire quickly: re-fetch on every + // (re)connect rather than reusing the first one. + token = null; + getToken().then( + t => socket!.emit('authenticate', t.socketToken), + () => { + onState?.('error'); + if (attempt >= 1) maybeReloadForAuth(); + clearAuthRetry(); + authRetry = setTimeout( + () => authenticate(attempt + 1), + Math.min(30_000, 2_000 * 2 ** attempt) + ); + } + ); + }; + const getSocket = () => { if (socket) return socket; socket = io(); socket.on('connect', () => { - // Socket tokens are single-use and expire quickly: re-fetch on every - // (re)connect rather than reusing the first one. - token = null; - getToken().then( - t => socket!.emit('authenticate', t.socketToken), - () => onState?.('error') - ); + clearAuthRetry(); + authenticate(); }); + socket.on('disconnect', () => clearAuthRetry()); socket.on('connect_error', () => onState?.('error')); + // Chrome freezes background tabs and laptops sleep; on wake, socket.io + // would sit out the rest of its backoff before trying again. Any wake + // signal — the tab becoming visible, the window refocusing, the network + // returning — skips the wait and reconnects now. + const wake = () => { + if (socket && !socket.connected) socket.connect(); + }; + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') wake(); + }); + window.addEventListener('focus', wake); + window.addEventListener('online', wake); return socket; }; From 0c9636ea5ed5865ccb106a28dd6da29bd8cd6554 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Wed, 22 Jul 2026 23:36:52 -0700 Subject: [PATCH 292/381] Board feedback batch: honest state, visible controls, your mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four changes from Danny's first-day feedback thread in #pulldasher, sharing one theme: the board should never move state the user can't see or predict. Drop v1 URL compatibility. legacy.ts translated v1 bookmarks (?repo=&cr=0&closed=1) into v2 state behind an undismissable chip. Nobody wants the old URLs; the translation layer, its filter branch, its Classic-only props (column collapse, the Recently Closed column), and its tests all go. "Requested of you" becomes "review requested". The standalone lane merged into Waiting on you some time ago; what remained was phrasing. "GitHub asked you directly" read oddly — a PERSON asked, GitHub carried the message. The row context and Legend now say "review requested", matching the toast title that already existed. Your own pulls wear your mark. v1 had a blue star; v2's star means "starred person", so ownership rides the identity mark itself: your avatar wears a 1.5px brand ring on every lens. One glyph, one treatment, no new vocabulary. "Changed since your last look" becomes "Recently updated (Clear)". The old model guessed attention: tab visibility plus a glance-guard timer advanced the baseline, and opening a PR silently removed its row. Both guesses were wrong in ways users could see but not explain — a hidden virtual desktop still counts as "visible", and click-vanish read as the board losing things. Now there is one state and one visible control: the lane's sub-line names the window out loud ("new or updated in the last 14d") and its Clear button is the only thing that moves the baseline. Retired: the visibility/pagehide stamping, the glance-guard setting, per-row acks, Settings' "mark everything seen" (the control lives at its point of effect now), and the shipped toast's dismiss handler that quietly cleared the ledger. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 99 +++----------------- frontend-v2/src/components/Card.tsx | 15 ++- frontend-v2/src/components/ClosedRow.tsx | 4 +- frontend-v2/src/components/Legend.tsx | 4 +- frontend-v2/src/components/Row.tsx | 9 +- frontend-v2/src/components/Settings.tsx | 38 +------- frontend-v2/src/components/bits.tsx | 7 +- frontend-v2/src/legacy.test.ts | 40 -------- frontend-v2/src/legacy.ts | 113 ----------------------- frontend-v2/src/model/actions.ts | 2 +- frontend-v2/src/settings.ts | 5 +- frontend-v2/src/store.ts | 80 +++------------- frontend-v2/src/styles.css | 7 ++ frontend-v2/src/views/Classic.tsx | 61 +++--------- frontend-v2/src/views/Review.tsx | 25 +++-- 15 files changed, 80 insertions(+), 429 deletions(-) delete mode 100644 frontend-v2/src/legacy.test.ts delete mode 100644 frontend-v2/src/legacy.ts diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 55fde98f..a506fbc3 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -7,7 +7,6 @@ import { useState, type ReactNode, } from 'react'; -import { X } from 'lucide-react'; import { ago, closedEpoch, n, pullKey, shortRepo } from './format'; import type { ActionStateKey } from './model/actions'; import { actionState } from './model/actions'; @@ -18,8 +17,7 @@ import { buildReviewerPools, turnFor } from './model/rotation'; import { shipRelevance, shippedToast } from './model/shipped'; import type { Toast } from './model/toast'; import type { Team as TeamGroup } from './types'; -import { claimReview, markAllSeen, setWeightLabels, usePulldasher } from './store'; -import { applyLegacyFilters, describeLegacyView, readLegacyView } from './legacy'; +import { claimReview, setWeightLabels, usePulldasher } from './store'; import { loadSiteConfig, primeScope, useScope } from './prefs'; import { getSettings, useSettings } from './settings'; import { useNotifications } from './notifications'; @@ -28,7 +26,6 @@ import { matchesQuery } from './model/query'; import { CRYO_KEY, isBotLogin, personHidden, repoHidden } from './model/visibility'; import { reviewRequestedFrom } from './model/reviewers'; import { foldDomId, openFold } from './components/Lane'; -import { Icon } from './components/Icon'; import { Legend } from './components/Legend'; import { Logo } from './components/Logo'; import { NotificationPanel } from './components/NotificationPanel'; @@ -188,36 +185,6 @@ function Banner({ ); } -/** Header pill toggle: brand-tinted while active, quiet outline otherwise. */ -function ToggleChip({ - active, - onClick, - title, - className = '', - children, -}: { - active: boolean; - onClick: () => void; - title?: string; - className?: string; - children: ReactNode; -}) { - return ( - - ); -} - export function App() { const { pulls, @@ -229,20 +196,12 @@ export function App() { authFailed, lastPayloadAt, lastSeen, - acked, refreshProgress, } = usePulldasher(); // desktop notifications watch the whole board, not the current filter useNotifications(pulls, me, initialized); const [scope, setScope] = useScope(); - // a v1 bookmark (?repo=…&author=…&cryo=1…) opens Classic configured the - // same way; the chip below shows what it applied and dismisses it - const [legacy, setLegacy] = useState(() => readLegacyView(location.search)); - const [lens, setLens] = useState(() => { - const h = readHash(); - if (legacy && !location.hash.includes('lens=')) return 'classic'; - return h.lens; - }); + const [lens, setLens] = useState(() => readHash().lens); const [person, setPerson] = useState(() => urlState.person); const [team, setTeam] = useState(() => urlState.team); const [query, setQuery] = useState(() => urlState.q); @@ -253,9 +212,7 @@ export function App() { const [stateSel, setStateSel] = useState(() => urlState.state); // "hidden" is two off-by-default groups (Cryogenic-Storage PRs, quiet // repos). showAll reveals both; reveal names individual groups to show. - const [showAll, setShowAll] = useState( - () => urlState.hidden || (!!legacy && (legacy.cryo || legacy.showAllRepos)) - ); + const [showAll, setShowAll] = useState(() => urlState.hidden); const [reveal, setReveal] = useState(() => urlState.reveal); const toggleReveal = useCallback( (key: string) => @@ -448,9 +405,8 @@ export function App() { (repo: string) => reveal.includes(repo) || scope.repos.includes(repo) || - !!legacy?.repos.includes(shortRepo(repo)) || queryRepos.some(q => shortRepo(repo).toLowerCase().includes(q)), - [reveal, scope.repos, legacy, queryRepos] + [reveal, scope.repos, queryRepos] ); // same idea as queryRepos/revealedRepo, but people have no reveal= list of // their own — muting is a plain two-state toggle, so a scope pick or an @@ -488,10 +444,8 @@ export function App() { // board, but not when you've deliberately looked (scoped/`author:`d // that person) or when GitHub explicitly requested your review — a // review-requested draft was vanishing even from the reviewer it was - // requested from, the reported bug. Your own drafts always show. The - // legacy path owns its own draft rule, so don't double-apply. + // requested from, the reported bug. Your own drafts always show. const draftHidden = - !legacy && draftsMode === 'mine' && p.data.draft && p.data.user.login !== me && @@ -508,7 +462,6 @@ export function App() { revealedRepo, revealedAuthor, hiddenRepos, - legacy, me, isBot, settings.repoPrefs, @@ -523,9 +476,7 @@ export function App() { // one weight class doesn't make the other classes' counts vanish (the // same reasoning PeopleFilter's authorCounts follows for its own pool) const preWeightScoped = useMemo(() => { - let out = pulls; - if (legacy) out = applyLegacyFilters(out, legacy, me); - out = out.filter(p => !boardHidden(p)); + let out = pulls.filter(p => !boardHidden(p)); if (scope.repos.length) out = out.filter(p => scope.repos.includes(p.data.repo)); // bots bypass the people filter on purpose: dependency bumps need review // no matter whose work you follow (they land in the bots fold, not lanes) @@ -533,7 +484,7 @@ export function App() { out = out.filter(p => isBot(p) || scope.authors.includes(p.data.user.login)); if (query) out = out.filter(p => matchesQuery(p, query, me)); return out; - }, [pulls, legacy, me, boardHidden, scope, isBot, query]); + }, [pulls, boardHidden, scope, isBot, query]); // preWeightScoped narrowed by the Weight filter, but not yet State — // StateFilter's own live counts read this pool for the same @@ -711,7 +662,6 @@ export function App() { () => ({ me, lastSeen, - acked, onPerson, onWeightToggle, maxAgeDays, @@ -727,7 +677,6 @@ export function App() { [ me, lastSeen, - acked, onPerson, onWeightToggle, maxAgeDays, @@ -812,16 +761,10 @@ export function App() { const base = shippedToast(backlog, me); if (!base) return []; const jump = shippedFoldCount > 0 ? jumpToShipped : undefined; - return [ - { - ...base, - onAct: () => { - jump?.(); - markAllSeen(); - }, - onGone: markAllSeen, - }, - ]; + // NOTE: dismissing this toast no longer touches the last-cleared + // baseline — a toast quietly expiring was wiping the "Recently + // updated" ledger; only that lane's Clear button moves the state + return [{ ...base, onAct: jump }]; }, [query, scopedClosed, lastSeen, me, jumpToShipped, shippedFoldCount]); // the quick-wins toast filters the board to the small reviewable ones on the @@ -983,19 +926,6 @@ export function App() { Reset )} - {legacy && ( - setLegacy(null)} - title={`filters from your v1 bookmark: ${describeLegacyView(legacy) || 'defaults'}. Click to drop them`} - className="max-w-[260px]" - > - - v1 view: {describeLegacyView(legacy) || 'defaults'} - - - - )} {bots.length > 0 && ( @@ -1085,12 +1015,7 @@ export function App() { /> )} {initialized && lens === 'classic' && ( - + )} {initialized && lens === 'ci' && } {initialized && lens === 'stats' && ( diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index cde695f8..5c5b43e3 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -78,13 +78,13 @@ export function CardShell({ number, title, body, - onOpen, id, className = '', meta, rail, stretch = true, compact = false, + own = false, avatarBadge, edge, depth = 0, @@ -97,7 +97,6 @@ export function CardShell({ title: string; /** PR description for the title's hover preview */ body?: string; - onOpen?: () => void; /** stable DOM id (format.ts's rowDomId) — lets toasts scroll to and * flash the row it just claimed. */ id?: string; @@ -111,6 +110,9 @@ export function CardShell({ rail?: ReactNode; stretch?: boolean; compact?: boolean; + /** the viewer authored this pull: their avatar wears the brand ring — + * v1's blue star's job, carried by the identity mark itself */ + own?: boolean; /** a tiny marker absolutely-positioned over the avatar (the row's starred- * author ★) — a slot rather than an Avatar prop, so this stays a one- * caller concern instead of touching every Avatar call site. */ @@ -135,7 +137,6 @@ export function CardShell({ number={number} title={title} body={body} - onOpen={onOpen} stretch={stretch} /> ); @@ -156,7 +157,9 @@ export function CardShell({ {connector} {/* raise only when the avatar is a real button — a raised inert span punches a dead zone into the whole-row click target */} - + {avatarBadge} @@ -185,7 +188,9 @@ export function CardShell({ style={{ paddingLeft: 14 + depth * STACK_INDENT_PX }} > {connector} - + {avatarBadge} diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index 03aa1231..0a99fe7e 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -30,8 +30,8 @@ export const ClosedRow = memo(function ClosedRow({ )} diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 76053b96..d99343b8 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -131,8 +131,8 @@ export function Legend() { def="hover a row for its verbs. Claiming adds you as a reviewer on the PR, so GitHub and the board both show you’re on it; the row moves to Waiting on you until you submit, release, or are removed" /> requested from you} - def="GitHub asked you directly. Lands in Waiting on you, and the board quiets its own suggestions for that PR" + term={review requested} + def="someone requested your review on GitHub. Lands in Waiting on you, and the board quiets its own suggestions for that PR" /> your turn} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index bf7e6386..e34f1395 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -14,7 +14,6 @@ import { useSettings, } from '../settings'; import { - ackPull, claimFor, claimReview, isFresh, @@ -49,9 +48,6 @@ export interface RowOptions { laneCap?: number; me: string; lastSeen: number; - /** pull key → epoch secs it was opened (clears the fresh dot until the - * pull changes again; persisted per-browser) */ - acked: Readonly>; onPerson?: (login: string) => void; /** age thresholds from user settings (fall back to the model's) */ ageWarnDays?: number; @@ -94,7 +90,7 @@ export interface RowOptions { * opening it clears the mark for the session. */ function freshKind(p: DerivedPull, opts: RowOptions): 'new' | 'updated' | null { - if (!isFresh(p.data, opts.lastSeen, opts.acked)) return null; + if (!isFresh(p.data, opts.lastSeen)) return null; return epoch(p.data.created_at) > opts.lastSeen ? 'new' : 'updated'; } @@ -705,7 +701,7 @@ function RowImpl({ number={d.number} title={d.title} body={d.body} - onOpen={() => ackPull(key)} + own={d.user.login === opts.me} id={rowDomId(d)} compact={opts.compact} depth={depth} @@ -811,7 +807,6 @@ export const Row = memo( a.opts.compact === b.opts.compact && a.opts.me === b.opts.me && a.opts.lastSeen === b.opts.lastSeen && - a.opts.acked === b.opts.acked && a.opts.onPerson === b.opts.onPerson && a.opts.ageWarnDays === b.opts.ageWarnDays && a.opts.maxAgeDays === b.opts.maxAgeDays && diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index ce9c7c20..ff21e6bd 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Settings as SettingsIcon, X } from 'lucide-react'; import { clearStoredPrefs } from '../storage'; -import { markAllSeen, refreshAll, usePulldasher } from '../store'; +import { refreshAll, usePulldasher } from '../store'; import { addCodeRegion, removeCodeRegion, @@ -132,7 +132,6 @@ export function Settings({ const { refreshProgress } = usePulldasher(); const panelRef = useRef(null); const triggerRef = useRef(null); - const [seenNote, setSeenNote] = useState(false); const [refreshNote, setRefreshNote] = useState(''); const [armReset, setArmReset] = useState(false); @@ -443,41 +442,6 @@ export function Settings({
- {/* snoozes live on the board now: the Review lens - shows its own Snoozed section with the wake-all */} -
- - set({ seenAfterSecs })} - /> - -
-
- { - markAllSeen(); - setSeenNote(true); - setTimeout(() => setSeenNote(false), 1600); - }} - > - Mark everything as seen - - - {seenNote ? 'done' : ''} - -
Reset every preference on this browser (theme, filters, muted repos, diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 77ab8a52..a100eaa5 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -123,7 +123,7 @@ export function FreshTag({ kind }: { kind: 'new' | 'updated' }) { ? { background: 'var(--badge-brand-bg)', color: 'var(--badge-brand-fg)' } : undefined } - title={isNew ? 'new since your last look' : 'updated since your last look'} + title={isNew ? 'new since you cleared' : 'updated since you cleared'} > {isNew ? 'new' : 'updated'} @@ -898,7 +898,6 @@ export function PullTitleLink({ number, title, body, - onOpen, stretch, }: { repo: string; @@ -907,8 +906,6 @@ export function PullTitleLink({ /** the PR description; when present, hovering the visible title previews * it rendered — the "can I act on this?" read without leaving the board */ body?: string; - /** fired when the user opens the PR — the row's natural "seen" ack */ - onOpen?: () => void; /** cover the whole row as one click target */ stretch?: boolean; }) { @@ -925,8 +922,6 @@ export function PullTitleLink({ className={`font-medium hover:underline hover:underline-offset-2 ${stretch ? 'pd-link' : ''}`} href={githubUrl(repo, number)} {...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})} - onClick={onOpen} - onAuxClick={onOpen} > {preview ? ( // hoverTriggerOnly scopes the hover door to the visible words: the diff --git a/frontend-v2/src/legacy.test.ts b/frontend-v2/src/legacy.test.ts deleted file mode 100644 index 3f9c27b9..00000000 --- a/frontend-v2/src/legacy.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { readLegacyView } from './legacy'; - -describe('readLegacyView', () => { - it('returns null when no v1 params are present', () => { - expect(readLegacyView('')).toBeNull(); - expect(readLegacyView('?utm_source=slack')).toBeNull(); - }); - - it('parses repo and author lists', () => { - const v = readLegacyView('?repo=ifixit,valkyrie&author=danielbeardsley'); - expect(v?.repos).toEqual(['ifixit', 'valkyrie']); - expect(v?.authors).toEqual(['danielbeardsley']); - expect(v?.showAllRepos).toBe(false); - }); - - it('treats SHOWALL as unfiltered but remembers it', () => { - const v = readLegacyView('?repo=SHOWALL'); - expect(v?.repos).toEqual([]); - expect(v?.showAllRepos).toBe(true); - }); - - it('applies v1 boolean defaults', () => { - const v = readLegacyView('?cryo=1'); - expect(v?.cryo).toBe(true); - expect(v?.externalBlock).toBe(true); // default show - expect(v?.drafts).toBe(false); // default hide - expect(v?.personal).toBe(false); - expect(v?.closed).toBe(false); - }); - - it('reads external_block=0 as hide', () => { - expect(readLegacyView('?external_block=0')?.externalBlock).toBe(false); - }); - - it('collects collapsed columns from ?cr=0-style flags', () => { - const v = readLegacyView('?cr=0&qa=0&ready=1'); - expect(v?.collapsed).toEqual(new Set(['cr', 'qa'])); - }); -}); diff --git a/frontend-v2/src/legacy.ts b/frontend-v2/src/legacy.ts deleted file mode 100644 index 4823b0d5..00000000 --- a/frontend-v2/src/legacy.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { DerivedPull } from './model/status'; -import { reviewRequestedFrom } from './model/reviewers'; -import { shortRepo } from './format'; - -/** - * v1 saved its settings in the query string, and years of bookmarks encode - * them: ?repo=…&author=…&cryo=1&personal=1&closed=1 plus per-column collapse - * flags. Those URLs must keep working when v2 answers them — a legacy URL - * opens the Classic lens with the same pulls visible and the same columns - * collapsed. This module owns that translation; nothing else reads the - * query string. - */ - -export interface LegacyView { - /** v1 ?cryo=1 — show Cryogenic Storage pulls */ - cryo: boolean; - /** v1 ?external_block=0 — hide externally-blocked pulls (default shows) */ - externalBlock: boolean; - /** v1 ?drafts=1 — show other people's drafts (default hides them) */ - drafts: boolean; - /** v1 ?personal=1 — only pulls you author, stamped, block, or joined */ - personal: boolean; - /** v1 ?closed=1 — the Recently Closed panel */ - closed: boolean; - /** v1 ?repo=a,b — short repo names; empty = the default set */ - repos: string[]; - /** v1 ?repo=SHOWALL — every repo including hide-by-default ones */ - showAllRepos: boolean; - /** v1 ?author=x,y — logins; empty = everyone */ - authors: string[]; - /** v1 ?ci=0&cr=0… — collapsed column ids */ - collapsed: Set; -} - -const BOOL_KEYS = ['cryo', 'external_block', 'drafts', 'personal', 'closed']; -export const LEGACY_COLUMN_IDS = ['ci', 'dep', 'ready', 'dev', 'cr', 'qa']; -const SHOWALL = 'SHOWALL'; - -export function readLegacyView(search: string): LegacyView | null { - const p = new URLSearchParams(search); - const known = [...BOOL_KEYS, ...LEGACY_COLUMN_IDS, 'repo', 'author']; - if (!known.some(k => p.has(k))) return null; - const list = (key: string) => - (p.get(key) ?? '') - .split(',') - .filter(Boolean) - .filter(v => v !== SHOWALL); - return { - cryo: p.get('cryo') === '1', - externalBlock: p.get('external_block') !== '0', - drafts: p.get('drafts') === '1', - personal: p.get('personal') === '1', - closed: p.get('closed') === '1', - repos: (p.get('repo') ?? '').includes(SHOWALL) ? [] : list('repo'), - showAllRepos: (p.get('repo') ?? '').includes(SHOWALL), - authors: list('author'), - collapsed: new Set(LEGACY_COLUMN_IDS.filter(k => p.get(k) === '0')), - }; -} - -/** v1 isMineViaAffiliation: author, any stamp or block of mine, or participant. */ -function isAffiliated(p: DerivedPull, me: string): boolean { - const s = p.data.status; - const mine = (sigs: { data: { user: { login: string } } }[]) => - sigs.some(sig => sig.data.user.login === me); - return ( - p.data.user.login === me || - mine(s.allCR) || - mine(s.allQA) || - mine(s.dev_block) || - mine(s.deploy_block) || - (p.data.participants ?? []).includes(me) - ); -} - -/** - * The filter pipeline a v1 URL implies, defaults included: v1 hid other - * people's drafts unless ?drafts=1, so a bookmark without the param expects - * them gone. - */ -export function applyLegacyFilters( - pulls: DerivedPull[], - view: LegacyView, - me: string -): DerivedPull[] { - let out = pulls; - // your own drafts, and any draft GitHub explicitly requested you review, - // stay visible even without ?drafts=1 (mirrors the modern board's rule) - if (!view.drafts) - out = out.filter( - p => !p.data.draft || p.data.user.login === me || reviewRequestedFrom(p, me) - ); - if (!view.externalBlock) out = out.filter(p => !p.externalBlock); - if (view.personal) out = out.filter(p => isAffiliated(p, me)); - if (view.repos.length) out = out.filter(p => view.repos.includes(shortRepo(p.data.repo))); - if (view.authors.length) out = out.filter(p => view.authors.includes(p.data.user.login)); - return out; -} - -/** One-line receipt for the chip so the user sees what the old URL applied. */ -export function describeLegacyView(view: LegacyView): string { - const parts: string[] = []; - if (view.repos.length) parts.push(`repo ${view.repos.join(', ')}`); - if (view.showAllRepos) parts.push('all repos'); - if (view.authors.length) parts.push(`author ${view.authors.join(', ')}`); - if (view.personal) parts.push('personal'); - if (!view.drafts) parts.push('drafts hidden'); - if (!view.externalBlock) parts.push('external hidden'); - if (view.cryo) parts.push('cryo shown'); - if (view.closed) parts.push('recently closed'); - if (view.collapsed.size) parts.push(`${view.collapsed.size} columns collapsed`); - return parts.join(' · '); -} diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index 36d6c8f5..2b527ab9 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -276,7 +276,7 @@ function withCoordination( if (requestedFromMe) return { action: 'Review it', - context: base.context ? `requested from you · ${base.context}` : 'requested from you', + context: base.context ? `review requested · ${base.context}` : 'review requested', }; if (turn === me) return { diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index 89b829b1..79226d42 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -24,8 +24,6 @@ export interface Settings { * the urgency weight always follows the OPENED clock — how long a pull * has been open is the truth the board ranks by. */ ageDisplay: 'opened' | 'updated'; - /** seconds of attention before leaving stamps "last seen" (the glance guard) */ - seenAfterSecs: number; /** per-repo override of the org baseline: 'mute' hides a shown repo, * 'show' reveals an org-hidden one. Absent = follow the org default. */ repoPrefs: Record; @@ -105,7 +103,6 @@ export const DEFAULT_SETTINGS: Settings = { defaultLens: 'review', ageWarnDays: STARVE_DAYS, ageDisplay: 'opened', - seenAfterSecs: 45, repoPrefs: {}, draftsMode: 'mine', showCryo: false, @@ -130,7 +127,7 @@ export const DEFAULT_SETTINGS: Settings = { const store = createPersistentStore('pd2.settings', DEFAULT_SETTINGS); -/** Plain getter for non-React readers (the store's glance guard). */ +/** Plain getter for non-React readers. */ export function getSettings(): Settings { return store.get(); } diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts index a2eab2a4..f26e93c4 100644 --- a/frontend-v2/src/store.ts +++ b/frontend-v2/src/store.ts @@ -25,11 +25,8 @@ export interface Snapshot { authFailed: boolean; /** epoch secs of the last payload from the server; 0 until one arrives */ lastPayloadAt: number; - /** epoch secs of the last-seen marker (previous visit's departure) */ + /** epoch secs of the last Clear — the "Recently updated" baseline */ lastSeen: number; - /** pull key → epoch secs it was opened: clears the fresh dot until the - * pull changes again. Persisted per-browser. */ - acked: Readonly>; /** pull key → epoch secs it was snoozed: hidden for a day or until it * changes. Persisted per-browser. */ snoozed: Readonly>; @@ -82,75 +79,26 @@ function noteRefreshArrival(key: string) { }, 4000); } -// The marker advances when you LEAVE (pagehide / tab hidden), not when you -// arrive — an accidental reload must not erase "changed since yesterday". -// And only after the page has actually been LOOKED AT: a two-second Monday -// glance on the way to Slack must not mark the weekend's forty changes as -// seen. "Looked at" = N cumulative visible seconds since the last stamp, -// where N is the user's glance-guard setting (seenAfterSecs). +// The marker moves ONLY by the user's hand — the "Recently updated" lane's +// Clear button. Earlier versions guessed attention from tab visibility plus a +// glance-guard timer and guessed wrong (a page on a hidden virtual desktop +// still counts as "visible"); an explicit control the user can see beats a +// heuristic they can't predict. New browsers start with a 6-hour window. let lastSeen = Number(readStorage(LAST_SEEN_KEY)) || Date.now() / 1000 - 6 * 3600; -let attendedSecs = 0; -let visibleSince: number | null = document.visibilityState === 'visible' ? Date.now() / 1000 : null; -const settleAttention = () => { - if (visibleSince != null) { - attendedSecs += Date.now() / 1000 - visibleSince; - visibleSince = null; - } -}; -const stampSeen = () => { - settleAttention(); - if (attendedSecs < getSettings().seenAfterSecs) return; - lastSeen = Date.now() / 1000; - writeStorage(LAST_SEEN_KEY, String(lastSeen)); - attendedSecs = 0; -}; -window.addEventListener('pagehide', stampSeen); -document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') stampSeen(); - else visibleSince = Date.now() / 1000; -}); -/** Settings action: treat everything on the board as seen, right now. */ +/** The Clear action: everything on the board right now is old news. */ export function markAllSeen() { lastSeen = Date.now() / 1000; writeStorage(LAST_SEEN_KEY, String(lastSeen)); - attendedSecs = 0; - schedulePublish(); -} - -// Per-row acknowledgment: opening a PR clears its fresh dot. Persisted with -// the TIME of the ack, not just the key, so a reload doesn't resurrect dots -// you already cleared — while a pull that changes again after the ack earns -// its dot back (the session-Set version hid later changes too). -const ACKED_KEY = 'pd2.acked'; -let acked: Record = {}; -try { - acked = JSON.parse(readStorage(ACKED_KEY) ?? '{}') ?? {}; -} catch { - acked = {}; -} -const saveAcked = () => { - // an ack older than the board's last-seen stamp can never affect a dot - // (updated_at > lastSeen implies updated_at > that ack) — prune, so the - // blob doesn't grow forever - for (const [k, at] of Object.entries(acked)) if (at < lastSeen) delete acked[k]; - writeStorage(ACKED_KEY, JSON.stringify(acked)); -}; -export function ackPull(key: string) { - acked[key] = Date.now() / 1000; - saveAcked(); schedulePublish(); } -/** The one fresh predicate: changed since your last look AND since you last - * opened it. */ -export function isFresh( - d: Pick, - lastSeenAt: number, - ackedAt: Readonly> -) { - const updated = epoch(d.updated_at); - return updated > lastSeenAt && updated > (ackedAt[`${d.repo}#${d.number}`] ?? 0); +/** The one fresh predicate: changed since the user last cleared. No per-row + * acks — opening a PR used to silently remove its row from "Recently + * updated", which read as the board losing things; one state, one visible + * control. */ +export function isFresh(d: Pick, lastSeenAt: number) { + return epoch(d.updated_at) > lastSeenAt; } // Snooze: "not today" for one PR. A snooze lasts a day, and any change to @@ -207,7 +155,6 @@ let snapshot: Snapshot = { authFailed, lastPayloadAt, lastSeen, - acked: { ...acked }, snoozed: { ...snoozed }, refreshProgress: null, }; @@ -275,7 +222,6 @@ function publish() { authFailed, lastPayloadAt, lastSeen, - acked: { ...acked }, snoozed: { ...snoozed }, refreshProgress: refreshTracking ? { diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 8a8f3abe..d2fb6474 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -879,3 +879,10 @@ input[type="checkbox"] { border: 1px solid var(--secondary); padding: 3px 8px; } + +/* your own pull's author mark: the avatar itself wears the brand — one ring, + no second glyph (the star means "starred person", not "you"). */ +.pd-own-avatar { + border-radius: 9999px; + box-shadow: 0 0 0 1.5px var(--brand); +} diff --git a/frontend-v2/src/views/Classic.tsx b/frontend-v2/src/views/Classic.tsx index 319377d0..66dd496b 100644 --- a/frontend-v2/src/views/Classic.tsx +++ b/frontend-v2/src/views/Classic.tsx @@ -1,11 +1,8 @@ import { crDone, qaDone, type DerivedPull } from '../model/status'; -import type { PullData } from '../types'; -import { closedEpoch, pullKey } from '../format'; import { EmptyState } from '../components/bits'; import { BoardColumn } from '../components/Column'; -import { laneShown, Truncated } from '../components/Lane'; +import { laneShown } from '../components/Lane'; import { WordGroupRows } from '../components/WordGroups'; -import { ClosedRow } from '../components/ClosedRow'; import type { RowOptions } from '../components/Row'; /** @@ -84,35 +81,9 @@ function Column({ ); } -/** v1's Recently Closed panel, honored for ?closed=1 bookmarks. */ -function ClosedColumn({ pulls, opts }: { pulls: PullData[]; opts: RowOptions }) { - const ordered = [...pulls].sort((a, b) => closedEpoch(b) - closedEpoch(a)); - return ( - - - {ordered.map(p => ( - - ))} - - - ); -} - -export function Classic({ - pulls, - opts, - collapsed, - closed, -}: { - pulls: DerivedPull[]; - opts: RowOptions; - /** v1 ?cr=0-style column collapse flags from a legacy URL */ - collapsed?: Set; - /** closed pulls to show when a legacy URL asked for ?closed=1 */ - closed?: PullData[] | null; -}) { +export function Classic({ pulls, opts }: { pulls: DerivedPull[]; opts: RowOptions }) { const me = opts.me; - if (!pulls.length && !closed?.length) { + if (!pulls.length) { return ; } const base = [...pulls].sort(defaultCompare(me)); @@ -141,28 +112,20 @@ export function Classic({ .filter(p => !qaDone(p) && !isDevBlocked(p) && !isDraft(p) && !p.conflict && passedCI(p)) .sort(qaCompare(me)); - // ids match v1's column collapse params (?ci=0&cr=0…) so old URLs map 1:1 - const columns: [string, string, DerivedPull[]][] = [ - ['ci', 'CI Blocked', ciBlocked], - ['dep', 'Deploy Blocked', deployBlocked], - ['ready', 'Ready', ready], - ['dev', 'Dev Blocked', devBlocked], - ['cr', 'CR', needsCr], - ['qa', 'QA', needsQa], + const columns: [string, DerivedPull[]][] = [ + ['CI Blocked', ciBlocked], + ['Deploy Blocked', deployBlocked], + ['Ready', ready], + ['Dev Blocked', devBlocked], + ['CR', needsCr], + ['QA', needsQa], ]; return (
- {columns.map(([id, title, list]) => ( - + {columns.map(([title, list]) => ( + ))} - {closed && }
); } diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 7215ddae..6c62c0d3 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -1,5 +1,5 @@ import { type DerivedPull, qaDone, type Status, weightRank } from '../model/status'; -import { pullKey } from '../format'; +import { ago, pullKey } from '../format'; import { crSort, starFirst } from '../model/sort'; import { matchedRegions, matchesRegion } from '../model/regions'; import { @@ -14,7 +14,7 @@ import { reviewRequestedFrom } from '../model/reviewers'; import { startHereReason } from '../model/cheers'; import { dealRank } from '../model/deal'; import { useSettings } from '../settings'; -import { claimFor, clearSnoozes, isFresh, isSnoozed, usePulldasher } from '../store'; +import { claimFor, clearSnoozes, isFresh, isSnoozed, markAllSeen, usePulldasher } from '../store'; import type { PullData } from '../types'; import { EmptyState, QuietButton } from '../components/bits'; import { Fold, FoldRows, Lane, laneShown, RestGroup, SubDoor, Truncated } from '../components/Lane'; @@ -231,12 +231,12 @@ export function Review({ const ciRed = byStatus('ci_red'); const drafts = byStatus('draft'); - // Changed since your last look: everything that moved (new or updated), - // collected at the top instead of a bar toggle — newest change first. A - // pull can also live in a lane below; this is the "what happened while I - // was away" glance, not an exclusive bucket. + // Recently updated: everything that moved since the user last hit Clear, + // newest first. A pull can also live in a lane below; this is the "what + // happened" glance, not an exclusive bucket — and only the Clear button + // empties it (nothing leaves the list silently). const changed = pulls - .filter(p => isFresh(p.data, opts.lastSeen, opts.acked)) + .filter(p => isFresh(p.data, opts.lastSeen)) .sort((a, b) => Date.parse(b.data.updated_at) - Date.parse(a.data.updated_at)); // GitHub asked you directly — the most concrete "review this" on the board, @@ -421,11 +421,18 @@ export function Review({ )} markAllSeen()}> + Clear + + } /> {/* below here is offered work, not owed work — the board's suggestion for what to pick up next, as distinct from "Waiting on you" above. The From 93eb1ce43a8e942f2037e7dff8dd7eb4e40521fc Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 00:01:53 -0700 Subject: [PATCH 293/381] The you-coin: your pulls carry v1's star, minted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brand ring around your own avatar didn't survive contact with the owner: a halo on a photo reads as selection/focus, and lands as decoration. Five candidates went head-to-head at real pixels — the ring, a "you" word in the meta line, a corner dot (reads as online presence), a brand-tinted repo#number (brand text implies a link), and v1's actual treatment, which turned out to be substitution: authoredByMe() replaced the avatar with a blue faStar outright. The pick is a hybrid of the ring and v1's star: the identity slot holds a minted coin — the brand ring as the coin's edge around a filled brand star, the same visual mass as a face so row rhythm holds. Glyph candidates were auditioned in the coin at 12px: a pen reads as an edit button, a feather reads as another pen, the @ (GitHub's own author:@me) was the runner-up; the star won on filled mass and a decade of v1 muscle memory. The star vocabulary stays one concept — people who matter to you: corner star on someone you starred, the full coin for its limit case, you. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 11 +++++++++++ frontend-v2/src/components/Card.tsx | 18 +++++++----------- frontend-v2/src/components/bits.tsx | 22 ++++++++++++++++++++++ frontend-v2/src/styles.css | 7 ------- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 052ca2ea..cdc4d12d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -126,6 +126,17 @@ a card is where it is, one of these layers is missing. ## Identity vs standing +**The you-coin.** The identity slot holds a face for everyone except the +viewer: your own pulls carry a minted mark instead — a brand ring (the +coin's edge) around a filled brand star, the same visual mass as an +avatar so row rhythm holds. v1's blue star, brought forward. Rejected on +the way here: a brand ring AROUND your avatar (a halo reads as +selection/focus and lands as decoration), a corner dot (reads as online +presence), recoloring the repo#number (brand text implies a link). The +star vocabulary is one concept — people who matter to you: corner star +on someone you starred, the full coin for its limit case, you. + + A mark has two layers: the **glyph names what it is; the treatment names its condition.** Encode state transitions by modifying a dimension of the existing mark, not by swapping symbols: diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index 5c5b43e3..762c4ead 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { Avatar, PullTitleLink } from './bits'; +import { Avatar, PullTitleLink, YouCoin } from './bits'; /** * The one row every lens renders. Two densities of the same content: @@ -110,8 +110,8 @@ export function CardShell({ rail?: ReactNode; stretch?: boolean; compact?: boolean; - /** the viewer authored this pull: their avatar wears the brand ring — - * v1's blue star's job, carried by the identity mark itself */ + /** the viewer authored this pull: the identity slot holds the you-coin + * (v1's blue star, minted) instead of their avatar */ own?: boolean; /** a tiny marker absolutely-positioned over the avatar (the row's starred- * author ★) — a slot rather than an Avatar prop, so this stays a one- @@ -157,10 +157,8 @@ export function CardShell({ {connector} {/* raise only when the avatar is a real button — a raised inert span punches a dead zone into the whole-row click target */} - - + + {own ? : } {avatarBadge} {/* nothing here truncates: the row flows as one tight line and @@ -188,10 +186,8 @@ export function CardShell({ style={{ paddingLeft: 14 + depth * STACK_INDENT_PX }} > {connector} - - + + {own ? : } {avatarBadge} diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index a100eaa5..4ebabf84 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -136,6 +136,28 @@ export function FreshTag({ kind }: { kind: 'new' | 'updated' }) { * (bots, deleted accounts, an offline CDN) — so we always render something, * never a broken-image glyph. */ +/** + * The you-coin: what stands in the identity slot when the pull is the + * viewer's own — v1's blue star, minted. A brand ring (the coin's edge) + * around a filled brand star, same mass as a face, so the row rhythm holds + * while yours reads at a glance. The star vocabulary is one concept, people + * who matter to you: a corner star on someone you starred, the full coin + * for its limit case — you. + */ +export function YouCoin({ size = 22 }: { size?: number }) { + return ( + + + + ); +} + function AvatarFace({ login, size }: { login: string; size: number }) { const [broken, setBroken] = useState(false); // OKLCH holds perceived lightness constant across the hue wheel — the old diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index d2fb6474..8a8f3abe 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -879,10 +879,3 @@ input[type="checkbox"] { border: 1px solid var(--secondary); padding: 3px 8px; } - -/* your own pull's author mark: the avatar itself wears the brand — one ring, - no second glyph (the star means "starred person", not "you"). */ -.pd-own-avatar { - border-radius: 9999px; - box-shadow: 0 0 0 1.5px var(--brand); -} From ca987033c40075892ceb9bd2a8eb685ab497874f Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 00:12:40 -0700 Subject: [PATCH 294/381] The seated-star seal: the ring parts and the star takes its place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The you-mark's final form, arrived at through a fifty-idea sweep. The previous iteration — ring around the avatar plus a star badge at the top-right corner — was close but was still TWO marks: a ring that faintly pleads "selected" and a badge that faintly pings "notification", three strokes negotiating one corner at 16px. The seal fuses them: the brand ring parts at the upper-right (an 80° gap, round-capped terminals) and the filled brand star sits IN the opening, on the ring's own circumference. One object. A parted ring cannot read as a focus ring, because focus rings never break — which retroactively solves the objection that sank the original plain halo. Your face stays; the star crowns the coin rather than replacing it. Both densities get exact geometry (22px avatar: r=13 ring, star at 45°; 16px: r=10, slightly wider gap), colors are --brand/--surface only so both themes come free, and the mark never animates: identity, not state. Candidates rejected across the whole arc are recorded in DESIGN.md so the decision holds its shape. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 23 ++++--- frontend-v2/src/components/Card.tsx | 14 ++++- frontend-v2/src/components/bits.tsx | 95 ++++++++++++++++++++++++----- 3 files changed, 105 insertions(+), 27 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index cdc4d12d..f36bfbca 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -126,15 +126,20 @@ a card is where it is, one of these layers is missing. ## Identity vs standing -**The you-coin.** The identity slot holds a face for everyone except the -viewer: your own pulls carry a minted mark instead — a brand ring (the -coin's edge) around a filled brand star, the same visual mass as an -avatar so row rhythm holds. v1's blue star, brought forward. Rejected on -the way here: a brand ring AROUND your avatar (a halo reads as -selection/focus and lands as decoration), a corner dot (reads as online -presence), recoloring the repo#number (brand text implies a link). The -star vocabulary is one concept — people who matter to you: corner star -on someone you starred, the full coin for its limit case, you. +**The seated-star seal.** Your own pulls keep your face; around it, a +brand ring parted at the upper-right with a filled brand star seated IN +the opening, on the ring's own circumference — one object, not a ring +plus a badge. A parted ring cannot read as a focus ring (focus rings +never break), which is what sank the plain halo. Static always: +identity, not state. Rejected on the way here: the plain brand ring +(selection halo), a corner dot (online presence), a brand repo#number +(reads as a link), v1's full star-substitution coin (loses the face), +and star-badge-riding-ring (two marks negotiating one corner; the seal +fuses them into one). Runner-up glyph was the @ (GitHub's author:@me); +the star won on filled mass and v1 muscle memory. The star vocabulary +is one concept — people who matter to you: corner star at bottom-right +on someone you starred, the star crowning the whole seal for its limit +case, you. A mark has two layers: the **glyph names what it is; the treatment names its diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index 762c4ead..e45ac816 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { Avatar, PullTitleLink, YouCoin } from './bits'; +import { Avatar, PullTitleLink, YouSeal } from './bits'; /** * The one row every lens renders. Two densities of the same content: @@ -158,7 +158,11 @@ export function CardShell({ {/* raise only when the avatar is a real button — a raised inert span punches a dead zone into the whole-row click target */} - {own ? : } + {own ? ( + + ) : ( + + )} {avatarBadge} {/* nothing here truncates: the row flows as one tight line and @@ -187,7 +191,11 @@ export function CardShell({ > {connector} - {own ? : } + {own ? ( + + ) : ( + + )} {avatarBadge} diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 4ebabf84..5737551b 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -137,23 +137,88 @@ export function FreshTag({ kind }: { kind: 'new' | 'updated' }) { * never a broken-image glyph. */ /** - * The you-coin: what stands in the identity slot when the pull is the - * viewer's own — v1's blue star, minted. A brand ring (the coin's edge) - * around a filled brand star, same mass as a face, so the row rhythm holds - * while yours reads at a glance. The star vocabulary is one concept, people - * who matter to you: a corner star on someone you starred, the full coin - * for its limit case — you. + * The seated-star seal: the mark for the viewer's own pulls. A brand ring + * around your avatar, parted at the upper-right, with a filled brand star + * seated IN the opening on the ring's own circumference — one object, not a + * ring plus a badge. A parted ring cannot read as a focus ring (focus rings + * never break), which is what sank the plain-halo attempt. Star vocabulary + * stays one concept, people who matter to you: corner star at bottom-right + * on someone you starred; the star crowning the whole seal is you. Static + * always — identity, not state. */ -export function YouCoin({ size = 22 }: { size?: number }) { +export function YouSeal({ + login, + size = 22, + onClick, +}: { + login: string; + size?: number; + onClick?: (login: string) => void; +}) { + const compact = size <= 16; return ( - - + + + {compact ? ( + + + + + ) : ( + + + + + )} ); } From 23f2f2829bb51d114f8251200d9ddcc2cdc0123a Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 00:14:42 -0700 Subject: [PATCH 295/381] Seal: the ring quiets to a tint, the star keeps the voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-strength brand on both ring and star made the seal the loudest thing on a quiet row. The mark needs one voice, not two at equal volume: the ring drops to 45% opacity — an edge you feel more than read — while the filled star stays full brand and carries the scan. Opacity rather than a second token, so both themes keep their own brand value and the tint tracks it for free. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/bits.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 5737551b..67e2e2f7 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -175,6 +175,7 @@ export function YouSeal({ r="10" fill="none" stroke="var(--brand)" + strokeOpacity="0.45" strokeWidth="1.5" strokeLinecap="round" strokeDasharray="47.1 15.7" @@ -204,6 +205,7 @@ export function YouSeal({ r="13" fill="none" stroke="var(--brand)" + strokeOpacity="0.45" strokeWidth="1.5" strokeLinecap="round" strokeDasharray="63.5 18.2" From 35d705cdc8afd18adbb8cb8630c3706b0a72d673 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 13:32:53 -0700 Subject: [PATCH 296/381] Fix hover verbs clipped in Classic's narrow columns In columns under 520px the rail wraps to a full-width line, so the verb dock's wide-mode anchor (right:100% of the rail) parked Claim/Snooze off the row's LEFT edge, where the lane's overflow-hidden clipped them to a 1-2px sliver. The narrow-column override that re-anchored the old icon cluster (right:0) was dropped in 7cf9588 when the cluster became the worded verb dock; restore the same anchoring for .pd-verbs. Verified live in 318px Classic columns: the dock now sits flush with the row's content edge. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/styles.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 8a8f3abe..f14f1b7d 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -434,6 +434,15 @@ label:has(> input:not(:disabled)) { justify-content: flex-start; position: relative; } + /* the verb dock hangs OFF the rail's left edge in wide rows (right:100%), + but a wrapped rail IS the row — right:100% would park the chips off the + row's left edge, where the lane's overflow-hidden clips them to a sliver. + Anchor them to the rail's right edge instead. */ + .pd-rail .pd-verbs { + right: 0; + left: auto; + margin-right: 0; + } } /* whole-card click target: the title link stretches an invisible layer over its positioned row, so clicking anywhere opens the PR. Genuinely From 83d93aa52ec54f98008e3684d664ad3e30d81583 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 13:33:22 -0700 Subject: [PATCH 297/381] Measure the sticky header's height exactly --header-h fed offsetHeight, which rounds to an integer, while the browser resolves the column headers' sticky top against the header's true fractional height. At any zoom other than 100% the rounding left a hairline gap above the stuck header, with already-scrolled rows showing through the seam. getBoundingClientRect().height costs nothing and closes it. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index a506fbc3..af2e2b8a 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -319,13 +319,20 @@ export function App() { }, []); // lane/section headers stick just below the app header; its height varies // (the toolbar wraps on narrow screens), so publish the measured height as - // --header-h for their sticky offset + // --header-h for their sticky offset. getBoundingClientRect, NOT + // offsetHeight: the browser resolves sticky offsets against the header's + // true fractional height, and offsetHeight's integer rounding leaves a + // hairline gap above the stuck header at non-100% zoom, with scrolled + // rows showing through it. const headerRef = useRef(null); useLayoutEffect(() => { const el = headerRef.current; if (!el) return; const publish = () => - document.documentElement.style.setProperty('--header-h', `${el.offsetHeight}px`); + document.documentElement.style.setProperty( + '--header-h', + `${el.getBoundingClientRect().height}px` + ); publish(); const ro = new ResizeObserver(publish); ro.observe(el); From 963046aa4f84bf27459953d6b57ca104f72de398 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 13:42:24 -0700 Subject: [PATCH 298/381] Naming pass: hide, CI failing, and a verb on every toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three vocabulary fixes from the owner's copy review: - "Mute" was the wrong concept for repos and people — nothing is silenced, the rows are hidden. Rename the actions, labels, and the model layer (repoPrefs 'mute'->'hide', mutedPeople->hiddenPeople) with a one-time migration of stored settings blobs, so nobody loses their hidden lists. Cheer kinds still "mute": silencing a notification is real muting. The kebab's Hide/Show entries gain the open/closed eye icons. - The CI family now speaks one grammar: "CI failing" (was "CI red" in five different surfaces), "CI running" (was the cryptic "CI…" row flag), "Waiting on CI" for the only-CI-left badge, and full-sentence glosses behind each. Past-tense events keep their tense ("CI failed" notification title). - Every show/hide checkbox now leads with its verb ("Show parked PRs", "Show drafts by others") — a noun label never said which way the checkbox pointed. The tersest glosses are rewritten as full sentences for someone without the board's domain knowledge, e.g. the Waiting-on-you ordering note now says what "urgent" and "oldest" actually mean. Co-Authored-By: Claude Fable 5 Rider: Row.tsx/bits.tsx/app.tsx each carried a few pure-reformatting hunks (no content change) that biome produced alongside nearby renames but weren't called out in any commit's mapping; folded here as the earliest commit touching those files. Team.tsx's hiddenPeople/hiddenSet rename shared one hunk with the namesMap/nameOf additions (commit 8's feature); took the whole hunk here per instructions rather than splitting it. --- frontend-v2/src/app.tsx | 44 +++++++------- frontend-v2/src/components/Row.tsx | 59 +++++++++++-------- frontend-v2/src/components/Settings.tsx | 4 +- frontend-v2/src/components/StatePopover.tsx | 15 ++--- frontend-v2/src/components/WordGroups.tsx | 13 ++-- frontend-v2/src/components/bits.tsx | 16 ++--- .../src/components/filters/HiddenPanel.tsx | 40 ++++++------- .../src/components/filters/PeopleFilter.tsx | 32 +++++----- .../src/components/filters/RepoFilter.tsx | 24 ++++---- frontend-v2/src/components/filters/shared.tsx | 4 +- frontend-v2/src/model/actions.test.ts | 8 +-- frontend-v2/src/model/actions.ts | 18 +++--- frontend-v2/src/model/cheers.ts | 4 +- frontend-v2/src/model/savedFilters.test.ts | 2 +- frontend-v2/src/model/savedFilters.ts | 4 +- frontend-v2/src/model/stack.ts | 2 +- frontend-v2/src/model/visibility.ts | 18 +++--- frontend-v2/src/settings.ts | 46 +++++++++++---- frontend-v2/src/views/Classic.tsx | 2 +- frontend-v2/src/views/Team.tsx | 11 ++-- frontend-v2/src/views/stats/FrictionCard.tsx | 2 +- 21 files changed, 199 insertions(+), 169 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index af2e2b8a..e32bb4eb 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -402,7 +402,7 @@ export function App() { // scope/query applied, but NOT the changed-only toggle: the changed count // must describe the pool the toggle would narrow, or the banner promises // rows the click doesn't deliver - // repos named in the query (repo:x) reveal a muted repo for this session — + // repos named in the query (repo:x) reveal a hidden repo for this session — // an explicit filter is an explicit "I want it now" const queryRepos = useMemo( () => (query ? [...query.matchAll(/repo:(\S+)/gi)].map(m => m[1].toLowerCase()) : []), @@ -428,12 +428,12 @@ export function App() { [scope.authors, queryAuthors] ); - // The one is-this-pull-off-the-board predicate: muted (user) and - // org-hidden repos, muted people, cryo, snoozes, and the drafts rule stay + // The one is-this-pull-off-the-board predicate: user-hidden and + // org-hidden repos, hidden people, cryo, snoozes, and the drafts rule stay // off unless a session act reveals them — an explicit reveal, a scope, a // repo:/author: query term, or the master "show everything". Never hides - // your own pulls or bots via mutes: bots have their own fold, and a person - // can't mute themselves off their own board. Shared by the filter pipeline + // your own pulls or bots via hides: bots have their own fold, and a person + // can't hide themselves from their own board. Shared by the filter pipeline // and the hidden-PR ledger's counts so the ledger's number can never // disagree with what the board actually withholds. const boardHidden = useCallback( @@ -444,7 +444,7 @@ export function App() { const hiddenPerson = p.data.user.login !== me && !isBot(p) && - personHidden(p.data.user.login, settings.mutedPeople) && + personHidden(p.data.user.login, settings.hiddenPeople) && !revealedAuthor(p.data.user.login); const cryoHidden = p.cryo && !settings.showCryo && !reveal.includes(CRYO_KEY); // drafts: 'mine' keeps other people's drafts quiet on the general @@ -473,7 +473,7 @@ export function App() { isBot, settings.repoPrefs, settings.showCryo, - settings.mutedPeople, + settings.hiddenPeople, draftsMode, ] ); @@ -541,7 +541,7 @@ export function App() { const hiddenPerson = p.user.login !== me && !isBotLogin(p.user.login, extraBots) && - personHidden(p.user.login, settings.mutedPeople) && + personHidden(p.user.login, settings.hiddenPeople) && !revealedAuthor(p.user.login); return !hiddenRepo && !hiddenPerson; }); @@ -553,7 +553,7 @@ export function App() { showAll, hiddenRepos, settings.repoPrefs, - settings.mutedPeople, + settings.hiddenPeople, revealedRepo, revealedAuthor, scope, @@ -565,21 +565,21 @@ export function App() { // rule covers, whether or not a session reveal currently shows it) plus // the live currently-hidden total for the trigger label const hiddenCounts = useMemo(() => { - const c = { parked: 0, drafts: 0, mutedRepos: 0, mutedPeople: 0, hiddenNow: 0 }; + const c = { parked: 0, drafts: 0, hiddenRepos: 0, hiddenPeople: 0, hiddenNow: 0 }; for (const p of pulls) { if (p.cryo) c.parked++; if (p.data.draft && p.data.user.login !== me && !reviewRequestedFrom(p, me)) c.drafts++; - if (repoHidden(p.data.repo, hiddenRepos, settings.repoPrefs)) c.mutedRepos++; + if (repoHidden(p.data.repo, hiddenRepos, settings.repoPrefs)) c.hiddenRepos++; if ( p.data.user.login !== me && !isBot(p) && - personHidden(p.data.user.login, settings.mutedPeople) + personHidden(p.data.user.login, settings.hiddenPeople) ) - c.mutedPeople++; + c.hiddenPeople++; if (boardHidden(p)) c.hiddenNow++; } return c; - }, [pulls, me, hiddenRepos, settings.repoPrefs, settings.mutedPeople, isBot, boardHidden]); + }, [pulls, me, hiddenRepos, settings.repoPrefs, settings.hiddenPeople, isBot, boardHidden]); const isScoped = scope.repos.length || scope.authors.length || query; @@ -927,7 +927,7 @@ export function App() { setReveal([]); setDraftsMode(settings.draftsMode); }} - title="clears scope and session toggles; mutes and stars stay" + title="clears scope and session toggles; your hidden and starred choices stay" className="hit pressable rounded-md px-1.5 py-1 text-[13px] text-ink-3 hover:text-brand" > Reset @@ -989,11 +989,11 @@ export function App() { )} {initialized && lens === 'review' && ( + pulls={humans} + bots={bots} + closed={scopedClosed} + opts={{ ...rowOpts, showSnooze: true }} + /> )} {initialized && lens === 'mine' && ( @@ -1021,9 +1021,7 @@ export function App() { extraBots={extraBots} /> )} - {initialized && lens === 'classic' && ( - - )} + {initialized && lens === 'classic' && } {initialized && lens === 'ci' && } {initialized && lens === 'stats' && ( diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index e34f1395..ae551384 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -1,5 +1,14 @@ import { memo, useState, type ReactNode } from 'react'; -import { AlarmClock, Copy, Diamond, EllipsisVertical, RefreshCw, Star } from 'lucide-react'; +import { + AlarmClock, + Copy, + Diamond, + EllipsisVertical, + Eye, + EyeOff, + RefreshCw, + Star, +} from 'lucide-react'; import type { DerivedPull } from '../model/status'; import { isIterating, lastPushEpoch, weightFilterKey } from '../model/status'; import { type Claim, rowNote } from '../model/actions'; @@ -8,7 +17,7 @@ import type { ParentRef } from '../model/stack'; import { ago, epoch, pullKey, rowDomId, shortRepo } from '../format'; import { setRepoPref, - toggleMutedPerson, + toggleHiddenPerson, togglePrimaryRepo, toggleStarredPerson, useSettings, @@ -65,7 +74,7 @@ export interface RowOptions { onWeightToggle?: (w: string) => void; /** whole-board parent lookup (model/stack.ts's buildParentLookup, memoized * once in app.tsx): resolves a dependent pull's parent even when it's - * absent from the CURRENT list (a different lane, a muted repo, another + * absent from the CURRENT list (a different lane, a hidden repo, another * lens' scope), so the 'stacked' flag can name it instead of just saying * "based on ". */ parentOf?: (p: DerivedPull) => ParentRef | null; @@ -192,7 +201,12 @@ function rowFlags( detail: 'GitHub hasn’t confirmed this merges cleanly yet.', }); if (p.ci === 'pending' && p.status !== 'ci_pending') - flags.push({ key: 'ci', tone: 'note', label: 'CI…', detail: 'CI is still running.' }); + flags.push({ + key: 'ci', + tone: 'note', + label: 'CI running', + detail: 'CI checks are still running on the latest push.', + }); if (showIterating) { const pushedAt = lastPushEpoch(p); flags.push({ @@ -323,8 +337,7 @@ function VerbDock({ const showSnoozeChip = showSnooze || snoozedNow; const showClaimChip = claimable || mine; if (!showSnoozeChip && !showClaimChip) return null; - const chip = - 'hit pressable rounded-md border-0 bg-muted px-1.5 py-1 text-xs whitespace-nowrap'; + const chip = 'hit pressable rounded-md border-0 bg-muted px-1.5 py-1 text-xs whitespace-nowrap'; return ( {showSnoozeChip && ( @@ -395,7 +408,7 @@ function RowActionsKebab({ const author = pull.data.user.login; const repoLabel = shortRepo(repo); const isPrimaryRepo = settings.primaryRepos.includes(repo); - const isMutedRepo = settings.repoPrefs[repo] === 'mute'; + const isHiddenRepo = settings.repoPrefs[repo] === 'hide'; const isStarredAuthor = settings.starredPeople.includes(author); const claimedByMe = claim?.login === me; const item = @@ -439,11 +452,7 @@ function RowActionsKebab({ )} {showSnooze && ( - @@ -484,15 +493,16 @@ function RowActionsKebab({ - {/* never offered for your own pulls — you can't mute yourself off + {/* never offered for your own pulls — you can't hide yourself from your own board */} {author !== me && ( )} @@ -593,9 +604,7 @@ function MetricRail({ const d = pull.data; const me = opts.me; return ( - + {/* one instrument, humans first: CR (label, weight letter, pips — one door into one panel), then QA, then the machine's circle at diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index ff21e6bd..64df903a 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -405,7 +405,7 @@ export function Settings({
- Repos: star and mute from the Repos filter in the header. + Repos: star and hide from the Repos filter in the header. @@ -444,7 +444,7 @@ export function Settings({ - Reset every preference on this browser (theme, filters, muted repos, + Reset every preference on this browser (theme, filters, hidden repos and people, last-seen marker) back to defaults. This can’t be undone.
diff --git a/frontend-v2/src/components/StatePopover.tsx b/frontend-v2/src/components/StatePopover.tsx index fb7872f5..b3bb68c5 100644 --- a/frontend-v2/src/components/StatePopover.tsx +++ b/frontend-v2/src/components/StatePopover.tsx @@ -127,12 +127,12 @@ function FactsSection({ const qaReq = d.status.qa_req; const ciWord = pull.ci === 'failing' - ? 'red' + ? 'failing' : pull.ci === 'pending' ? 'running' : pull.ci === 'success' - ? 'green' - : 'none required'; + ? 'passed' + : 'no checks required'; // a failing check's name links straight to its run log (the status's // target_url) — "which check, and show me" without a GitHub detour const redLogs = new Map( @@ -173,9 +173,7 @@ function FactsSection({ {srcLink(`${githubUrl(d.repo, d.number)}/conflicts`, 'resolve on GitHub →')}

)} - {d.closes != null && ( -

closes {srcLink(issueUrl(d.repo, d.closes), `#${d.closes}`)}

- )} + {d.closes != null &&

closes {srcLink(issueUrl(d.repo, d.closes), `#${d.closes}`)}

} {d.connects != null && (

connects {srcLink(issueUrl(d.repo, d.connects), `#${d.connects}`)}

)} @@ -200,10 +198,7 @@ function FactsSection({ pull.ciFailing.map(ctx => ( {' · '} - {srcLink( - redLogs.get(ctx) || `${githubUrl(d.repo, d.number)}/checks`, - ctx - )} + {srcLink(redLogs.get(ctx) || `${githubUrl(d.repo, d.number)}/checks`, ctx)} ))}

diff --git a/frontend-v2/src/components/WordGroups.tsx b/frontend-v2/src/components/WordGroups.tsx index 0725386a..5de8fcc4 100644 --- a/frontend-v2/src/components/WordGroups.tsx +++ b/frontend-v2/src/components/WordGroups.tsx @@ -29,7 +29,8 @@ const WORD_GLOSS: Record = { Unblock: 'A block of yours is what holds it. Lift it when you’re ready.', Rebase: 'Your PR conflicts with its base branch.', 'Nudge CR': 'Your PR, and nobody has reviewed it yet. Worth a ping.', - 'Find QA-er': 'CR isn’t the gate here, QA is. Line someone up to test it.', + 'Find a QA-er': + 'Your PR doesn’t need more code review, it needs someone to test it. Line a tester up.', Review: 'Open PRs you could code review.', QA: 'Open PRs you could test.', Undraft: 'Your draft. Mark it ready on GitHub when you want review.', @@ -44,9 +45,9 @@ const WORD_GLOSS: Record = { 'Someone is testing it right now: they added the QAing label on GitHub. Add it yourself to claim a QA.', claimed: 'Someone flagged they’re reading it.', stamped: 'Your stamp is in; waiting on the rest of the sign-offs.', - 'CI running': 'Checks are still running.', - 'CI red': - 'A required check is failing; the author fixes that first. Stale stamps wait too: nobody is asked to re-stamp until it’s green.', + 'CI running': 'CI checks are still running on the latest push.', + 'CI failing': + 'A required CI check is failing; the author fixes that first. Stale stamps wait too: nobody is asked to re-stamp until the build is green.', blocked: 'Someone left a dev block; the author owes changes first. Stale stamps wait until the block lifts.', 'deploy hold': 'Done, but deliberately not shipped yet.', @@ -55,9 +56,9 @@ const WORD_GLOSS: Record = { 'on hold': 'Blocked on something outside this repo.', parked: 'Labeled Cryogenic Storage: shelved on purpose. Nothing is asked of anyone while it’s parked.', - ready: 'Fully signed off and green; waiting on the author to merge.', + ready: 'Fully signed off and green; anyone can merge it, usually the author does.', draft: 'Not up for review yet.', - waiting: 'Waiting, and the board can’t say on what.', + waiting: 'Waiting on something GitHub’s data doesn’t name — open the PR to see.', }; export interface WordGroup { diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 67e2e2f7..8b751b9c 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -32,9 +32,10 @@ import { Popover } from './Popover'; export const STATUS_LABEL: Record = { ready: 'Ready to merge', - // only at the ready gate: fully signed off, nothing left but a green build. - // 'CI running' would lie — a needs-CR pull can have CI running too. - ci_pending: 'Only CI left', + // only at the ready gate: fully signed off, CI is the one thing left. + // 'CI running' would lie — a needs-CR pull can have CI running too — so + // the badge names the WAIT, not the machinery. + ci_pending: 'Waiting on CI', needs_recr: 'Needs re-CR', needs_qa: 'Needs QA', needs_cr: 'Needs CR', @@ -43,7 +44,7 @@ export const STATUS_LABEL: Record = { dev_block: 'Dev blocked', deploy_block: 'Deploy block', unmergeable: 'Can’t merge', - ci_red: 'CI red', + ci_red: 'CI failing', draft: 'Draft', }; @@ -64,8 +65,8 @@ export const STATUS_DOT: Record = { /** * The quiet outlined button the settings surfaces share — one definition so a - * radius or hover tweak lands everywhere. sm = inline row actions (mute, - * unmute), md = standalone panel actions. tone='brand' for the affirmative + * radius or hover tweak lands everywhere. sm = inline row actions (hide, + * show), md = standalone panel actions. tone='brand' for the affirmative * variant ("Show for me"). */ export function QuietButton({ @@ -413,8 +414,7 @@ export function CiStatus({ pull }: { pull: DerivedPull }) { : [...headStatuses(pull.data)].sort( (a, b) => ciRank(a) - ciRank(b) || a.data.context.localeCompare(b.data.context) ); - if (!checks.length) - return ; + if (!checks.length) return ; const failing = checks.filter(isRedCheck).length; const passing = checks.filter(c => c.data.state === 'success').length; diff --git a/frontend-v2/src/components/filters/HiddenPanel.tsx b/frontend-v2/src/components/filters/HiddenPanel.tsx index bc26a9fc..eac71baa 100644 --- a/frontend-v2/src/components/filters/HiddenPanel.tsx +++ b/frontend-v2/src/components/filters/HiddenPanel.tsx @@ -12,10 +12,10 @@ export interface HiddenCounts { parked: number; /** other people's drafts (yours always show) */ drafts: number; - /** PRs in repos you muted or the org hides by default */ - mutedRepos: number; - /** PRs by people you muted */ - mutedPeople: number; + /** PRs in repos you hid or the org hides by default */ + hiddenRepos: number; + /** PRs by people you hid */ + hiddenPeople: number; /** PRs currently off the board, after session reveals */ hiddenNow: number; } @@ -23,7 +23,7 @@ export interface HiddenCounts { /** * The board's hidden-PR ledger: one quiet door at the end of the filter bar * that always says how many open PRs you are NOT seeing, and opens into the - * why — parked, drafts, muted, snoozed — each with its count. The two + * why — parked, drafts, hidden repos and people — each with its count. The two * categories with clean session toggles (parked, drafts) toggle right here; * the durable ones say where their controls live instead of duplicating * them. Replaces the parked-PRs checkbox that hid at the bottom of the Repos @@ -54,7 +54,7 @@ export function HiddenPanel({ // the undo is one click, mirroring how an active filter clears const revealing = showAll || reveal.length > 0 || draftsMode !== settings.draftsMode; const anythingHidden = - counts.parked + counts.drafts + counts.mutedRepos + counts.mutedPeople > 0; + counts.parked + counts.drafts + counts.hiddenRepos + counts.hiddenPeople > 0; if (!anythingHidden && !revealing) return null; const resetReveals = () => { @@ -137,12 +137,12 @@ export function HiddenPanel({ {counts.parked > 0 && toggleRow( 'parked', - 'Parked PRs', + 'Show parked PRs', counts.parked, parkedShown, settings.showCryo || showAll, () => toggleReveal(CRYO_KEY), - 'Labeled Cryogenic Storage: long-running work, set aside on purpose.', + 'PRs labeled Cryogenic Storage: long-running work, set aside on purpose. Check the box to show them for this session.', !settings.showCryo && reveal.includes(CRYO_KEY) ? () => setSettings({ showCryo: true }) : undefined @@ -150,27 +150,27 @@ export function HiddenPanel({ {counts.drafts > 0 && toggleRow( 'drafts', - 'Drafts by others', + 'Show drafts by others', counts.drafts, draftsShown, showAll, () => setDraftsMode(draftsMode === 'all' ? 'mine' : 'all'), - 'Your own drafts always show.', + 'Check the box to show other people’s drafts too; your own drafts always show.', draftsMode !== settings.draftsMode ? () => setSettings({ draftsMode }) : undefined )} - {counts.mutedRepos > 0 && + {counts.hiddenRepos > 0 && infoRow( - 'muted-repos', - 'In repos you muted', - counts.mutedRepos, - 'Show or unmute them under Repos.' + 'hidden-repos', + 'In repos you hid', + counts.hiddenRepos, + 'Show them again from the Repos filter.' )} - {counts.mutedPeople > 0 && + {counts.hiddenPeople > 0 && infoRow( - 'muted-people', - 'By people you muted', - counts.mutedPeople, - 'Unmute them under People.' + 'hidden-people', + 'By people you hid', + counts.hiddenPeople, + 'Show them again from the People filter.' )}
); }; - const hiddenRow = (name: string, count: number, state: 'muted' | 'org-hidden') => ( + const hiddenRow = (name: string, count: number, state: 'hidden' | 'org-hidden') => (
{count || ''} - {state === 'muted' ? ( - setRepoPref(name, null)}>Unmute + {state === 'hidden' ? ( + setRepoPref(name, null)}>Show ) : ( setRepoPref(name, 'show')}> Show @@ -170,13 +170,13 @@ export function RepoFilter({ )} > - {/* muted & org-hidden ("drafts") ride at the top, collapsed, so the - one-click reveal is the first thing you reach — matching the - Settings repo manager's muted→hidden→shown order */} + {/* hidden repos ride at the top, collapsed, so the one-click + reveal is the first thing you reach — matching the Settings + repo manager's hidden→shown order */} {filteredHidden.length > 0 && (
- Muted & org-hidden ({filteredHidden.length}) + Hidden by you & org-hidden ({filteredHidden.length})
diff --git a/frontend-v2/src/components/filters/shared.tsx b/frontend-v2/src/components/filters/shared.tsx index de7d29c3..bd842179 100644 --- a/frontend-v2/src/components/filters/shared.tsx +++ b/frontend-v2/src/components/filters/shared.tsx @@ -66,7 +66,7 @@ export function FilterTrigger({ * right now — what the bar's "Reset" resets, and what the saved-filters * panel reads to decide whether there's anything worth bookmarking. One * definition so the two surfaces can't disagree about what counts as - * "active". Durable state (mutes, stars, defaults) is deliberately not here. + * "active". Durable state (hidden, stars, defaults) is deliberately not here. */ export function hasActiveFilters({ reveal, @@ -141,7 +141,7 @@ export function FilterRow({ children }: { children: ReactNode }) { * The "only" quick-action: narrows a filter-picker's selection to exactly * this one row's item, deselecting everything else. Hidden until the row is * hovered or the button itself is keyboard-focused, so it never crowds the - * row's existing checkbox/star/mute buttons while still being reachable + * row's existing checkbox/star/hide buttons while still being reachable * without a mouse. Mirrors v1's filter "only" feature. */ export function OnlyButton({ onClick }: { onClick: () => void }) { diff --git a/frontend-v2/src/model/actions.test.ts b/frontend-v2/src/model/actions.test.ts index a7e58f74..4e7dfca5 100644 --- a/frontend-v2/src/model/actions.test.ts +++ b/frontend-v2/src/model/actions.test.ts @@ -329,7 +329,7 @@ describe('rowNote — the author matrix', () => { it('ci_pending', () => { expect(note({ status: 'ci_pending' }, me)).toEqual({ action: null, - context: 'CI running, then merge', + context: 'merge when it goes green', }); }); @@ -449,7 +449,7 @@ describe('rowNote — the non-author matrix', () => { // about to change, so the note is the wait, not "Re-stamp" expect(note({ author, status: 'ci_red', recrBy: ['me'] }, me)).toEqual({ action: null, - context: 'CI red · author fixes', + context: 'CI failing · author fixes', }); expect(note({ author, status: 'draft', recrBy: ['me'] }, me)).toEqual({ action: null, @@ -473,7 +473,7 @@ describe('rowNote — the non-author matrix', () => { it('ci_red', () => { expect(note({ author, status: 'ci_red' }, me)).toEqual({ action: null, - context: 'CI red · author fixes', + context: 'CI failing · author fixes', }); }); @@ -620,7 +620,7 @@ describe('rowNote — the non-author matrix', () => { it('ci_pending', () => { expect(note({ author, status: 'ci_pending' }, me)).toEqual({ action: null, - context: 'only CI left', + context: 'all stamps in, waiting on green', }); }); diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index 2b527ab9..cdee40bf 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -118,14 +118,14 @@ const unique = (xs: string[]): string[] => [...new Set(xs)]; */ const FALLBACK_STATUS_LABEL: Record = { ready: 'ready to merge', - ci_pending: 'only CI left', + ci_pending: 'waiting on CI', needs_recr: 'needs re-CR', needs_qa: 'needs QA', needs_cr: 'needs CR', dev_block: 'dev blocked', deploy_block: 'deploy block', unmergeable: 'can’t merge', - ci_red: 'CI red', + ci_red: 'CI failing', draft: 'draft', }; @@ -195,7 +195,7 @@ function authorNote(p: DerivedPull, me: string): RowNote { p.crHave > 0 ? `in the CR queue · ${p.crHave} of ${crReq}` : 'in the CR queue' ); } - if (p.status === 'ci_pending') return waitOnly('CI running, then merge'); + if (p.status === 'ci_pending') return waitOnly('merge when it goes green'); if (p.status === 'deploy_block') return waitOnly(`ask ${who(p.deployBlockedBy)} before deploy`); return waitOnly(FALLBACK_STATUS_LABEL[p.status]); @@ -325,7 +325,7 @@ function reviewerNote( } if (p.status === 'draft') return waitOnly('draft, not reviewable yet'); - if (p.status === 'ci_red') return waitOnly('CI red · author fixes'); + if (p.status === 'ci_red') return waitOnly('CI failing · author fixes'); if (p.status === 'dev_block') return waitOnly( p.devBlockedBy.includes(me) @@ -382,7 +382,7 @@ function reviewerNote( if (p.status === 'deploy_block') return waitOnly(`ask ${who(p.deployBlockedBy)} first`); if (p.status === 'unmergeable') return waitOnly(p.conflict ? 'conflicts · author rebases' : 'lands with its parent'); - if (p.status === 'ci_pending') return waitOnly('only CI left'); + if (p.status === 'ci_pending') return waitOnly('all stamps in, waiting on green'); if (p.status === 'ready') return waitOnly(`ready · nudge ${author} if it sits`); return waitOnly(FALLBACK_STATUS_LABEL[p.status]); @@ -481,7 +481,7 @@ const DO_WORD: Record = { Unblock: 'Unblock', Rebase: 'Rebase', 'Nudge for a review': 'Nudge CR', - 'Find a QA-er': 'Find QA-er', + 'Find a QA-er': 'Find a QA-er', 'Review it': 'Review', 'QA it': 'QA', Undraft: 'Undraft', @@ -512,7 +512,7 @@ function waitWord(p: DerivedPull, me: string, extra?: { claim?: Claim | null }): case 'draft': return 'draft'; case 'ci_red': - return 'CI red'; + return 'CI failing'; case 'dev_block': return 'blocked'; case 'deploy_block': @@ -576,7 +576,7 @@ export const DO_WORD_RANK: readonly string[] = [ 'Unblock', 'Rebase', 'Nudge CR', - 'Find QA-er', + 'Find a QA-er', 'Review', 'QA', 'Undraft', @@ -593,7 +593,7 @@ export const WAIT_WORD_RANK: readonly string[] = [ 'claimed', 'stamped', 'CI running', - 'CI red', + 'CI failing', 'blocked', 'deploy hold', 'conflicts', diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index 182c6ca9..cef40317 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -713,7 +713,7 @@ export const CHEER_CATALOG: { { kind: 'pr-ci-red', group: 'author', - label: 'CI broke on your PR', + label: 'CI failing on your PR', hint: 'A required check went red on your PR.', }, { @@ -1173,7 +1173,7 @@ export function diffCheers( { tone: 'nag', icon: '🔴', - title: 'CI broke on your PR', + title: 'CI failing on your PR', body: p.ciFailing?.length ? `Fix ${p.ciFailing.join(', ')}.` : 'Fix the build.', pull: pullRef(p), dedupeKey: `cired:${key}`, diff --git a/frontend-v2/src/model/savedFilters.test.ts b/frontend-v2/src/model/savedFilters.test.ts index 2f04d4e5..83dd41e6 100644 --- a/frontend-v2/src/model/savedFilters.test.ts +++ b/frontend-v2/src/model/savedFilters.test.ts @@ -42,7 +42,7 @@ describe('describeHash', () => { it('shows repo/author counts and drafts/hidden flags', () => { expect(describeHash('repos=ifixit,valkyrie&authors=al&drafts=all&hidden=1')).toBe( - 'repo: ifixit, valkyrie · author: al · drafts: all · hidden shown' + 'repo: ifixit, valkyrie · author: al · drafts: all · showing hidden' ); }); diff --git a/frontend-v2/src/model/savedFilters.ts b/frontend-v2/src/model/savedFilters.ts index 2d4ce24f..375b6787 100644 --- a/frontend-v2/src/model/savedFilters.ts +++ b/frontend-v2/src/model/savedFilters.ts @@ -5,7 +5,7 @@ import { createPersistentStore } from '../storage'; * (app.tsx) writes to location.hash: lens, q, repos, authors, weight, state, * hidden, reveal, drafts. `hash` is stored verbatim (no leading '#') so * applying one is just `location.hash = hash`; the app's existing hashchange - * listener does the rest. Durable per-browser prefs (mutes, stars, myTeam) + * listener does the rest. Durable per-browser prefs (hidden repos/people, stars, myTeam) * are deliberately NOT part of this — they aren't in the hash to begin with. */ export interface SavedFilter { @@ -107,7 +107,7 @@ export function describeHash(hash: string): string { list('authors', 'author'); const drafts = p.get('drafts'); if (drafts) parts.push(`drafts: ${drafts}`); - if (p.get('hidden') === '1') parts.push('hidden shown'); + if (p.get('hidden') === '1') parts.push('showing hidden'); const person = p.get('person'); if (person) parts.push(`person: ${person}`); const team = p.get('team'); diff --git a/frontend-v2/src/model/stack.ts b/frontend-v2/src/model/stack.ts index dfef0669..76c9b905 100644 --- a/frontend-v2/src/model/stack.ts +++ b/frontend-v2/src/model/stack.ts @@ -131,7 +131,7 @@ export interface ParentRef { * The whole-board parent lookup: same resolution rule as `groupIntoTree`, but * over every pull on the board — not just the filtered/scoped list a given * lane renders — so a pull whose parent exists somewhere else (a different - * lane, a muted repo, another lens' scope) can still be named instead of just + * lane, a hidden repo, another lens' scope) can still be named instead of just * saying "based on ". Build once (memoized over the full pull set in * app.tsx) and thread through RowOptions.parentOf. */ diff --git a/frontend-v2/src/model/visibility.ts b/frontend-v2/src/model/visibility.ts index ce993dd9..3d8ea294 100644 --- a/frontend-v2/src/model/visibility.ts +++ b/frontend-v2/src/model/visibility.ts @@ -5,19 +5,19 @@ * authority — a per-user override beats the org baseline — and a session * reveal (scope, query, the show= set) can still override the result for * right now (that lives in app.tsx, not here). People have no org baseline: - * muting is a plain two-state toggle. + * hiding is a plain two-state toggle. */ /** the reveal-set sentinel for Cryogenic-Storage PRs (repo names fill the rest) */ export const CRYO_KEY = 'cryo'; -export type RepoState = 'shown' | 'muted' | 'org-hidden'; +export type RepoState = 'shown' | 'hidden' | 'org-hidden'; -type RepoPrefs = Record; +type RepoPrefs = Record; /** * The durable state of a repo: your override wins, else the org baseline. - * 'muted' = you hid it; 'org-hidden' = off by default for everyone and you + * 'hidden' = you hid it; 'org-hidden' = off by default for everyone and you * haven't unhidden it; 'shown' = on your board. */ export function repoState( @@ -26,12 +26,12 @@ export function repoState( prefs: RepoPrefs ): RepoState { const pref = prefs[repo]; - if (pref === 'mute') return 'muted'; + if (pref === 'hide') return 'hidden'; if (pref === 'show') return 'shown'; return orgHidden.has(repo) ? 'org-hidden' : 'shown'; } -/** Hidden by default (muted or org-hidden) — before any session reveal. */ +/** Hidden by default (you hid it, or org-hidden) — before any session reveal. */ export function repoHidden( repo: string, orgHidden: ReadonlySet, @@ -47,8 +47,8 @@ export function isBotLogin(login: string, extra: ReadonlySet): boolean { return login.endsWith('[bot]') || extra.has(login); } -/** A person has no org baseline the way a repo does — muting is a plain +/** A person has no org baseline the way a repo does — hiding is a plain * two-state toggle, not a three-way state like repoState. */ -export function personHidden(login: string, muted: string[]): boolean { - return muted.includes(login); +export function personHidden(login: string, hidden: string[]): boolean { + return hidden.includes(login); } diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index 79226d42..36ac985c 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -24,9 +24,9 @@ export interface Settings { * the urgency weight always follows the OPENED clock — how long a pull * has been open is the truth the board ranks by. */ ageDisplay: 'opened' | 'updated'; - /** per-repo override of the org baseline: 'mute' hides a shown repo, + /** per-repo override of the org baseline: 'hide' hides a shown repo, * 'show' reveals an org-hidden one. Absent = follow the org default. */ - repoPrefs: Record; + repoPrefs: Record; /** your default for other people's drafts: 'mine' hides them (your own * always show), 'all' shows everyone's */ draftsMode: 'mine' | 'all'; @@ -76,9 +76,9 @@ export interface Settings { starredPeople: string[]; /** logins whose pulls stay off your board until an explicit reveal (a * scope pick or an author: query term) brings them back for the session. - * Mirrors repoPrefs' mute, but people have no org baseline to fall back - * to — muting is the whole state. */ - mutedPeople: string[]; + * Mirrors repoPrefs' hide, but people have no org baseline to fall back + * to — hidden is the whole state. */ + hiddenPeople: string[]; /** free-text areas you own or care about (e.g. "Growthbook", "Shopify"). * A PR whose title, body, labels, branch, or repo partial-matches any of * these floats to the top of the review queue. Arbitrary strings, not a @@ -118,7 +118,7 @@ export const DEFAULT_SETTINGS: Settings = { primaryRepos: [], myTeam: [], starredPeople: [], - mutedPeople: [], + hiddenPeople: [], codeRegions: [], claimWarnMins: 120, openPrsNewTab: true, @@ -127,6 +127,30 @@ export const DEFAULT_SETTINGS: Settings = { const store = createPersistentStore('pd2.settings', DEFAULT_SETTINGS); +// "Mute" grew up into "hide" for repos and people — hiding is what actually +// happens (cheer kinds still mute: silencing a notification is real muting). +// Old saved blobs speak the old vocabulary; translate once on load and +// persist, so everything downstream reads only 'hide'/hiddenPeople. +{ + type LegacyBlob = Omit & { + mutedPeople?: string[]; + repoPrefs: Record; + }; + const raw = store.get() as unknown as LegacyBlob; + const hadMutedRepos = Object.values(raw.repoPrefs).includes('mute'); + if (hadMutedRepos || raw.mutedPeople?.length) { + const repoPrefs = Object.fromEntries( + Object.entries(raw.repoPrefs).map(([r, p]) => [r, p === 'mute' ? 'hide' : p]) + ) as Record; + const { mutedPeople, ...rest } = raw; + store.set({ + ...rest, + repoPrefs, + hiddenPeople: rest.hiddenPeople.length ? rest.hiddenPeople : (mutedPeople ?? []), + }); + } +} + /** Plain getter for non-React readers. */ export function getSettings(): Settings { return store.get(); @@ -137,7 +161,7 @@ export function setSettings(patch: Partial) { } /** Set or clear one repo's visibility override. null follows the org default. */ -export function setRepoPref(repo: string, pref: 'mute' | 'show' | null) { +export function setRepoPref(repo: string, pref: 'hide' | 'show' | null) { const next = { ...store.get().repoPrefs }; if (pref == null) delete next[repo]; else next[repo] = pref; @@ -174,11 +198,11 @@ export function toggleStarredPerson(login: string, on: boolean) { setSettings({ starredPeople: next }); } -/** Mute or unmute a person. Deduped and sorted for a stable render order. */ -export function toggleMutedPerson(login: string, on: boolean) { - const cur = store.get().mutedPeople; +/** Hide or unhide a person's pulls. Deduped and sorted for a stable render order. */ +export function toggleHiddenPerson(login: string, on: boolean) { + const cur = store.get().hiddenPeople; const next = on ? [...new Set([...cur, login])].sort() : cur.filter(l => l !== login); - setSettings({ mutedPeople: next }); + setSettings({ hiddenPeople: next }); } /** Add a code region (trimmed). Deduped case-insensitively so "Shopify" and diff --git a/frontend-v2/src/views/Classic.tsx b/frontend-v2/src/views/Classic.tsx index 66dd496b..521e798e 100644 --- a/frontend-v2/src/views/Classic.tsx +++ b/frontend-v2/src/views/Classic.tsx @@ -113,7 +113,7 @@ export function Classic({ pulls, opts }: { pulls: DerivedPull[]; opts: RowOption .sort(qaCompare(me)); const columns: [string, DerivedPull[]][] = [ - ['CI Blocked', ciBlocked], + ['CI Failing', ciBlocked], ['Deploy Blocked', deployBlocked], ['Ready', ready], ['Dev Blocked', devBlocked], diff --git a/frontend-v2/src/views/Team.tsx b/frontend-v2/src/views/Team.tsx index 76bb19ac..562bbfc3 100644 --- a/frontend-v2/src/views/Team.tsx +++ b/frontend-v2/src/views/Team.tsx @@ -57,9 +57,12 @@ export function Team({ // no explicit choice (the door follows the selection); true/false = the // user's own toggle for this visit const [directoryChoice, setDirectoryChoice] = useState(null); - const { myTeam, codeRegions, starredPeople, mutedPeople } = useSettings(); + const { myTeam, codeRegions, starredPeople, hiddenPeople } = useSettings(); + // login -> human name for the directory chips (app.tsx prefetches the board) + const namesMap = useNames(); + const nameOf = (login: string) => displayName(namesMap, login); const starredSet = new Set(starredPeople); - const mutedSet = new Set(mutedPeople); + const hiddenSet = new Set(hiddenPeople); // authored/owed counts read the UNSCOPED pool: a narrowed scope shouldn't // change what a chip says about a person's real backlog @@ -105,11 +108,11 @@ export function Team({ : null; const shipping = theirs.filter(p => ['ready', 'needs_qa'].includes(p.status)).length; - // the directory: starred people lead, muted ones drop out unless an + // the directory: starred people lead, hidden ones drop out unless an // explicit pick (a URL or a click) already landed on them; your team's // members are pinned in their own row above, so they don't repeat here const logins = [...new Set([...counts.keys(), ...owes.keys()])] - .filter(l => (!mutedSet.has(l) || l === selectedPerson) && !myTeam.includes(l)) + .filter(l => (!hiddenSet.has(l) || l === selectedPerson) && !myTeam.includes(l)) .sort( (a, b) => Number(starredSet.has(b)) - Number(starredSet.has(a)) || diff --git a/frontend-v2/src/views/stats/FrictionCard.tsx b/frontend-v2/src/views/stats/FrictionCard.tsx index a2bb2212..cf69ac07 100644 --- a/frontend-v2/src/views/stats/FrictionCard.tsx +++ b/frontend-v2/src/views/stats/FrictionCard.tsx @@ -14,7 +14,7 @@ const ROWS: { key: keyof Friction; label: string; dot: string; hint: string }[] dot: 'var(--warn)', hint: 'feedback waiting on the author', }, - { key: 'ciRed', label: 'CI red', dot: 'var(--bad)', hint: 'a required check is failing' }, + { key: 'ciRed', label: 'CI failing', dot: 'var(--bad)', hint: 'a required CI check is failing' }, { key: 'deployBlocked', label: 'deploy blocked', From d0b8acb2652cf783a7b43adf6c27bc41d0007314 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 13:42:35 -0700 Subject: [PATCH 299/381] Promote Ready to merge into Pick up next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fully signed-off, green, cleanly-merging PR is finishable work for anyone on this team — the author usually lands it, but anyone can. As a collapsed fold at the bottom of the rest group, merge-ready work sat invisible; it now stands as a real lane at the end of Pick up next, longest-waiting first. Bot PRs that reach ready join it — a human has to press their merge button too, which was the strongest argument against building a separate bots lens. This view's fold glosses also pick up the naming pass ("CI failing", full-sentence conflict and grouping explanations). Co-Authored-By: Claude Fable 5 --- frontend-v2/src/views/Review.tsx | 72 ++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 6c62c0d3..9141e1aa 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -124,7 +124,9 @@ export function Review({ ) .sort(bySecurityThenAge); const botKeys = new Set(botReviewable.map(p => pullKey(p.data))); - const botRest = bots.filter(p => !botKeys.has(pullKey(p.data))).sort(bySecurityThenAge); + const botRest = bots + .filter(p => !botKeys.has(pullKey(p.data)) && p.status !== 'ready') + .sort(bySecurityThenAge); const isDemoted = (p: DerivedPull) => botKeys.has(pullKey(p.data)); // 4. Needs QA is a query, not the status bucket: QA runs in parallel with @@ -202,9 +204,14 @@ export function Review({ nonStarved.filter(p => !isPrimaryRepo(p.data.repo) && !regionKeys.has(pullKey(p.data))) ); - // Ready-to-merge is the author's button, not the reviewer's job: a count - // in the rest group, not a lane at the top. - const ready = others.filter(p => p.status === 'ready'); + // Ready to merge is finishable work for anyone: fully signed off, green, + // one button-press from done. It earns a real lane in Pick up next rather + // than a fold — and bot PRs that reach ready join it, since a human has to + // land them. Longest-waiting first: the ones most likely forgotten. + const ready = [ + ...others.filter(p => p.status === 'ready'), + ...bots.filter(p => p.status === 'ready'), + ].sort((a, b) => b.ageDays - a.ageDays); const needsQa = starFirst( qaSort(qaPool.filter(p => isPrimaryRepo(p.data.repo) && !regionKeys.has(pullKey(p.data)))), @@ -324,7 +331,8 @@ export function Review({ !yoursWaiting.length && !changed.length && !queue.length && - !needsQa.length; + !needsQa.length && + !ready.length; // the rest group itself earns a title only when it has something inside — // an empty "rest of the board" with 11 closed folds under it is still noise. @@ -333,7 +341,6 @@ export function Review({ const restTotal = queueOther.length + needsQaOther.length + - ready.length + devBlocked.length + deployHeld.length + unmergeable.length + @@ -375,8 +382,9 @@ export function Review({ claimed.

- Grouped by the action, most urgent action first, oldest first inside a - group. + Each group is named for the action you’d take (Re-stamp, Respond, Merge…). + The kinds of action that unblock other people come first; inside a group, + the PR that has been open the longest comes first.

} @@ -405,7 +413,10 @@ export function Review({ your own PRs waiting on a review, QA, or CI, plus PRs you’ve already stamped that are still waiting on another reviewer.

-

Grouped by what each one waits on.

+

+ Each group is named for what the PR is waiting on (a review, a tester, CI, a + re-stamp), so you can see where everything of yours is stuck. +

} pulls={[]} @@ -437,7 +448,10 @@ export function Review({ {/* below here is offered work, not owed work — the board's suggestion for what to pick up next, as distinct from "Waiting on you" above. The label only earns its place when something is actually on offer. */} - {(queue.length > 0 || needsQa.length > 0 || regionMatches.length > 0) && ( + {(queue.length > 0 || + needsQa.length > 0 || + regionMatches.length > 0 || + ready.length > 0) && (
Pick up next
)} {codeRegions.length > 0 && regionMatches.length > 0 && ( @@ -510,6 +524,28 @@ export function Review({ cap={6} opts={{ ...opts, rankReason: whyQaNext }} /> + +

+ Everything is done on these: code review and QA are in, CI is green, and they + merge cleanly. The author usually lands their own PR, but anyone can — merge + it, or nudge the author if it’s been sitting. +

+

+ Bot PRs land here too once they’re fully green: they only ship when a human + merges them. Longest-waiting first. +

+ + } + pulls={ready} + cap={6} + opts={opts} + /> {(restTotal > 0 || napping.length > 0) && ( - - - @@ -565,15 +593,15 @@ export function Review({ From eda84f2f4bdcc39c07111d71df16302a6b4daef9 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 13:43:53 -0700 Subject: [PATCH 300/381] The silhouette and the star: new author-identity marks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seated-star seal is gone — even parted, ring-ness read as chrome around the face ("I don't like the seal at all"). From a 20-concept sweep, the system that won puts every distinction on the form rung: - Bots are rounded-square tiles (the app-icon idiom Slack and GitHub already taught); people are circles. Shape alone answers person-or-machine at 16px, no glyph spent. - The bottom-right corner answers what-this-person-is-to-you in one glyph: a 9px star on someone you starred, and a 13px star seated ON your own avatar's rim with a transparent bite masked out of the face — your silhouette is visibly broken, so your rows read in peripheral vision. Size and form escalate; fill never does. The bite is a mask, not a painted stroke, so it stays correct over any row background — the seal's surface-colored star outline never quite did. The star rides inside the hover-scaling button, so bite and star can't fall out of registration. Two dummy pulls now author as renovate[bot]/dependabot[bot] so the demo board exercises the tiles, the bot fold, and the queue tail; the Legend gains an Authors band. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 47 +- frontend-v2/src/app.tsx | 10 +- frontend-v2/src/backend/dummy-pulls.json | 15981 +++++++++++---------- frontend-v2/src/components/Card.tsx | 37 +- frontend-v2/src/components/Legend.tsx | 31 +- frontend-v2/src/components/Row.tsx | 12 +- frontend-v2/src/components/bits.tsx | 169 +- 7 files changed, 8179 insertions(+), 8108 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index f36bfbca..d1082618 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -126,20 +126,39 @@ a card is where it is, one of these layers is missing. ## Identity vs standing -**The seated-star seal.** Your own pulls keep your face; around it, a -brand ring parted at the upper-right with a filled brand star seated IN -the opening, on the ring's own circumference — one object, not a ring -plus a badge. A parted ring cannot read as a focus ring (focus rings -never break), which is what sank the plain halo. Static always: -identity, not state. Rejected on the way here: the plain brand ring -(selection halo), a corner dot (online presence), a brand repo#number -(reads as a link), v1's full star-substitution coin (loses the face), -and star-badge-riding-ring (two marks negotiating one corner; the seal -fuses them into one). Runner-up glyph was the @ (GitHub's author:@me); -the star won on filled mass and v1 muscle memory. The star vocabulary -is one concept — people who matter to you: corner star at bottom-right -on someone you starred, the star crowning the whole seal for its limit -case, you. +**The silhouette and the star** — the author-identity system, two +channels and no ring: + +- **Shape answers person-or-machine.** People are circles; bots (a + `[bot]` login or config.json's `bots` list) are rounded-square tiles, + the app-icon idiom Slack and GitHub already taught. No glyph, no hue: + the outline is the whole mark, so it reads at 16px and in peripheral + vision. +- **The bottom-right corner answers what-this-person-is-to-you, in one + glyph.** A small (9px) brand star on someone you starred; on your own + avatar, a larger (13px/11px) brand star seated ON the rim with a + transparent bite masked out of the face — your silhouette is visibly + broken, so your rows are findable by *form* before the star even + resolves. You are the star vocabulary's largest case, escalated by + size and a broken outline, never by fill. The bite is a mask, not a + painted stroke, so it stays correct over any row background (hover, + fresh flash). +- Static always: identity, not state. Never animates. + +Rejected on the way here, in order: the plain brand ring (selection +halo), a corner dot (online presence), a brand repo#number (reads as a +link), v1's full star-substitution coin (loses the face), +star-badge-riding-ring (two marks negotiating one corner), and the +seated-star seal itself — a parted ring with the star in the opening — +which shipped, then died on the owner's read ("I don't like the seal at +all"): even parted, ring-ness reads as chrome around the face rather +than a mark of it. From the 20-concept sweep that followed, the +runner-ups were a pure size bump (your face ~5px larger; zero new +vocabulary but marginal in far periphery) and a brand mat behind your +avatar (unmissable but the board's first filled color area on rows). +The corner-bot-glyph variant of full unification lost to the square +silhouette: a 9px machine glyph needs bespoke drawing to survive +rasterization, and shape gets the same distinction free. A mark has two layers: the **glyph names what it is; the treatment names its diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index e32bb4eb..7425d065 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -225,10 +225,10 @@ export function App() { ); const [teams, setTeams] = useState([]); const [extraBots, setExtraBots] = useState>(new Set()); - const isBot = useCallback( - (p: DerivedPull) => isBotLogin(p.data.user.login, extraBots), - [extraBots] - ); + // login-level twin of isBot, for row-level consumers (the avatar's square + // bot tile) that hold a login rather than a DerivedPull + const isBotAuthor = useCallback((login: string) => isBotLogin(login, extraBots), [extraBots]); + const isBot = useCallback((p: DerivedPull) => isBotAuthor(p.data.user.login), [isBotAuthor]); // theme, density, default view, age colors, glance guard — all live in // settings now (the cog panel), persisted per-browser const settings = useSettings(); @@ -680,6 +680,7 @@ export function App() { parentOf, pools, turns, + isBotAuthor, }), [ me, @@ -696,6 +697,7 @@ export function App() { parentOf, pools, turns, + isBotAuthor, ] ); diff --git a/frontend-v2/src/backend/dummy-pulls.json b/frontend-v2/src/backend/dummy-pulls.json index 7e84c94b..98496d59 100644 --- a/frontend-v2/src/backend/dummy-pulls.json +++ b/frontend-v2/src/backend/dummy-pulls.json @@ -1,8061 +1,8092 @@ [ - { - "repo": "iFixit/ifixit", - "number": 33495, - "state": "open", - "title": "IE11: Run in parallel with chrome tests instead of sequentially", - "body": "pull request dummy body", - "created_at": "2020-07-08T22:55:22.000Z", - "updated_at": "2020-10-16T21:58:04.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "ie11--run-in-parallel-with-chrome-tests", + { + "repo": "iFixit/ifixit", + "number": 33495, + "state": "open", + "title": "IE11: Run in parallel with chrome tests instead of sequentially", + "body": "pull request dummy body", + "created_at": "2020-07-08T22:55:22.000Z", + "updated_at": "2020-10-16T21:58:04.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "ie11--run-in-parallel-with-chrome-tests", + "sha": "ee18a55031b8b1e86dbe240552dfe86f344559d7", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "BaseInfinity" + }, + "assignees": [ + "danielbeardsley" + ], + "requested_reviewers": [ + "ianrohde", + "danielbeardsley" + ], + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 33495, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-07-08T23:47:35.000Z", + "active": 1, + "comment_id": 655814623 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 33495, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-07-08T23:47:35.000Z", + "active": 1, + "comment_id": 655814623 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 33495, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-07-08T23:10:20.000Z", + "active": 0, + "comment_id": 655803840 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 33495, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "dev_block", + "created_at": "2020-07-08T23:10:20.000Z", + "active": 1, + "comment_id": 655803840 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "ee18a55031b8b1e86dbe240552dfe86f344559d7", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "ee18a55031b8b1e86dbe240552dfe86f344559d7", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { "sha": "ee18a55031b8b1e86dbe240552dfe86f344559d7", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "BaseInfinity" - }, - "assignees": ["danielbeardsley"], - "requested_reviewers": ["ianrohde", "danielbeardsley"], - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 33495, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "CR", - "created_at": "2020-07-08T23:47:35.000Z", - "active": 1, - "comment_id": 655814623 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 33495, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "CR", - "created_at": "2020-07-08T23:47:35.000Z", - "active": 1, - "comment_id": 655814623 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 33495, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "CR", - "created_at": "2020-07-08T23:10:20.000Z", - "active": 0, - "comment_id": 655803840 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 33495, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "dev_block", - "created_at": "2020-07-08T23:10:20.000Z", - "active": 1, - "comment_id": 655803840 - } - } - ], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "ee18a55031b8b1e86dbe240552dfe86f344559d7", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "ee18a55031b8b1e86dbe240552dfe86f344559d7", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "ee18a55031b8b1e86dbe240552dfe86f344559d7", - "target_url": "https://wwww.example.com", - "description": "Merge Failed", - "state": "error", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "QAE", - "number": 33495, - "repo": "iFixit/ifixit", - "user": "davidrans", - "created_at": "2020-07-23T19:51:19.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Merge Failed", + "state": "error", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAE", + "number": 33495, "repo": "iFixit/ifixit", - "number": 33597, - "state": "open", - "title": "Font: Change from Lato to Inter", - "body": "pull request dummy body", - "created_at": "2020-07-17T17:58:11.000Z", - "updated_at": "2020-12-16T01:10:08.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "test-inter-font", + "user": "davidrans", + "created_at": "2020-07-23T19:51:19.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 33597, + "state": "open", + "title": "Font: Change from Lato to Inter", + "body": "pull request dummy body", + "created_at": "2020-07-17T17:58:11.000Z", + "updated_at": "2020-12-16T01:10:08.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "test-inter-font", + "sha": "a22a3d8e132c90c21b535b099a2d6e21b5520098", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "participants": [ + "ianrohde", + "Another" + ], + "user": { + "login": "ianrohde" + }, + "assignees": [ + "danielbeardsley", + "ianrohde" + ], + "requested_reviewers": [ + "rjmccluskey" + ], + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 33597, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "CR", + "created_at": "2020-07-30T22:51:27.000Z", + "active": 0, + "comment_id": 666758987 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 33597, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "CR", + "created_at": "2020-07-30T20:45:46.000Z", + "active": 0, + "comment_id": 666684901 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 33597, + "user": { + "id": 129007, + "login": "kwiens" + }, + "type": "dev_block", + "created_at": "2020-08-28T15:30:11.000Z", + "active": 1, + "comment_id": 682715246 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 33597, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "deploy_block", + "created_at": "2020-08-27T19:37:53.000Z", + "active": 1, + "comment_id": 682151085 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 33597, + "user": { + "id": 129007, + "login": "kwiens" + }, + "type": "deploy_block", + "created_at": "2020-07-17T22:53:29.000Z", + "active": 1, + "comment_id": 660370664 + } + } + ], + "commit_statuses": [ + { + "data": { "sha": "a22a3d8e132c90c21b535b099a2d6e21b5520098", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "participants": ["ianrohde", "Another"], - "user": { - "login": "ianrohde" - }, - "assignees": ["danielbeardsley", "ianrohde"], - "requested_reviewers": ["rjmccluskey"], - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 33597, - "user": { - "id": 4808, - "login": "scbarber" - }, - "type": "CR", - "created_at": "2020-07-30T22:51:27.000Z", - "active": 0, - "comment_id": 666758987 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 33597, - "user": { - "id": 4808, - "login": "scbarber" - }, - "type": "CR", - "created_at": "2020-07-30T20:45:46.000Z", - "active": 0, - "comment_id": 666684901 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 33597, - "user": { - "id": 129007, - "login": "kwiens" - }, - "type": "dev_block", - "created_at": "2020-08-28T15:30:11.000Z", - "active": 1, - "comment_id": 682715246 - } - } - ], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 33597, - "user": { - "id": 1424113, - "login": "BaseInfinity" - }, - "type": "deploy_block", - "created_at": "2020-08-27T19:37:53.000Z", - "active": 1, - "comment_id": 682151085 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 33597, - "user": { - "id": 129007, - "login": "kwiens" - }, - "type": "deploy_block", - "created_at": "2020-07-17T22:53:29.000Z", - "active": 1, - "comment_id": 660370664 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "a22a3d8e132c90c21b535b099a2d6e21b5520098", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "a22a3d8e132c90c21b535b099a2d6e21b5520098", - "target_url": "https://wwww.example.com", - "description": "Build failure", - "state": "failure", - "context": "phpunit" - } - }, - { - "data": { - "sha": "a22a3d8e132c90c21b535b099a2d6e21b5520098", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "QAing", - "number": 33597, - "repo": "iFixit/ifixit", - "user": "k0rvusk0r4x", - "created_at": "2020-08-27T22:31:56.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "a22a3d8e132c90c21b535b099a2d6e21b5520098", + "target_url": "https://wwww.example.com", + "description": "Build failure", + "state": "failure", + "context": "phpunit" + } + }, + { + "data": { + "sha": "a22a3d8e132c90c21b535b099a2d6e21b5520098", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 33597, "repo": "iFixit/ifixit", - "number": 34221, - "state": "open", - "title": "Cap hosts tasks: Drop old tasks", - "body": "pull request dummy body", - "created_at": "2020-09-04T01:28:45.000Z", - "updated_at": "2020-11-18T23:15:50.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "hosts--always-use-dynamic", + "user": "k0rvusk0r4x", + "created_at": "2020-08-27T22:31:56.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 34221, + "state": "open", + "title": "Cap hosts tasks: Drop old tasks", + "body": "pull request dummy body", + "created_at": "2020-09-04T01:28:45.000Z", + "updated_at": "2020-11-18T23:15:50.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "hosts--always-use-dynamic", + "sha": "167b03a21379d9440f8269c2dfc2f81c1877c64d", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 34221, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-09-04T20:08:21.000Z", + "active": 0, + "comment_id": 687356997 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 34221, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-09-04T16:49:32.000Z", + "active": 0, + "comment_id": 687264914 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 34221, + "user": { + "id": 589425, + "login": "sterlinghirsh" + }, + "type": "CR", + "created_at": "2020-09-04T10:07:57.000Z", + "active": 0, + "comment_id": 687054656 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 34221, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "deploy_block", + "created_at": "2020-11-12T20:10:36.000Z", + "active": 1, + "comment_id": 726313998 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 34221, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "deploy_block", + "created_at": "2020-09-08T17:33:28.000Z", + "active": 1, + "comment_id": 689029370 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "167b03a21379d9440f8269c2dfc2f81c1877c64d", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { "sha": "167b03a21379d9440f8269c2dfc2f81c1877c64d", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "danielbeardsley" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 34221, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-09-04T20:08:21.000Z", - "active": 0, - "comment_id": 687356997 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 34221, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "CR", - "created_at": "2020-09-04T16:49:32.000Z", - "active": 0, - "comment_id": 687264914 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 34221, - "user": { - "id": 589425, - "login": "sterlinghirsh" - }, - "type": "CR", - "created_at": "2020-09-04T10:07:57.000Z", - "active": 0, - "comment_id": 687054656 - } - } - ], - "dev_block": [], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 34221, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "deploy_block", - "created_at": "2020-11-12T20:10:36.000Z", - "active": 1, - "comment_id": 726313998 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 34221, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "deploy_block", - "created_at": "2020-09-08T17:33:28.000Z", - "active": 1, - "comment_id": 689029370 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "167b03a21379d9440f8269c2dfc2f81c1877c64d", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "167b03a21379d9440f8269c2dfc2f81c1877c64d", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "167b03a21379d9440f8269c2dfc2f81c1877c64d", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "external_block", - "number": 34221, - "repo": "iFixit/ifixit", - "user": "ardelato", - "created_at": "2020-11-18T23:15:50.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "167b03a21379d9440f8269c2dfc2f81c1877c64d", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "external_block", + "number": 34221, "repo": "iFixit/ifixit", - "number": 34925, - "state": "open", - "title": "Grafana: Provisioning scan takes too long causing setting page to fail on save.", - "body": "pull request dummy body", - "created_at": "2020-10-28T01:02:33.000Z", - "updated_at": "2020-11-10T22:16:17.000Z", - "participants": ["danielbeardsley", "someone else"], - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "grafana--async-provisioning-scan", + "user": "ardelato", + "created_at": "2020-11-18T23:15:50.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 34925, + "state": "open", + "title": "Grafana: Provisioning scan takes too long causing setting page to fail on save.", + "body": "pull request dummy body", + "created_at": "2020-10-28T01:02:33.000Z", + "updated_at": "2020-11-10T22:16:17.000Z", + "participants": [ + "danielbeardsley", + "someone else" + ], + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "grafana--async-provisioning-scan", + "sha": "2c1e0b325b221799eecc245b359f8a77093be19e", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "hackalot805" + }, + "assignees": [ + "ardelato" + ], + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 34925, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "CR", + "created_at": "2020-10-30T16:31:36.000Z", + "active": 0, + "comment_id": 719657649 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 34925, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "dev_block", + "created_at": "2020-11-03T17:20:21.000Z", + "active": 1, + "comment_id": 721267100 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 34925, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "deploy_block", + "created_at": "2020-10-30T16:31:36.000Z", + "active": 1, + "comment_id": 719657649 + } + } + ], + "commit_statuses": [ + { + "data": { "sha": "2c1e0b325b221799eecc245b359f8a77093be19e", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "hackalot805" - }, - "assignees": ["ardelato"], - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 34925, - "user": { - "id": 1424113, - "login": "BaseInfinity" - }, - "type": "CR", - "created_at": "2020-10-30T16:31:36.000Z", - "active": 0, - "comment_id": 719657649 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 34925, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "dev_block", - "created_at": "2020-11-03T17:20:21.000Z", - "active": 1, - "comment_id": 721267100 - } - } - ], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 34925, - "user": { - "id": 1424113, - "login": "BaseInfinity" - }, - "type": "deploy_block", - "created_at": "2020-10-30T16:31:36.000Z", - "active": 1, - "comment_id": 719657649 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "2c1e0b325b221799eecc245b359f8a77093be19e", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "2c1e0b325b221799eecc245b359f8a77093be19e", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "2c1e0b325b221799eecc245b359f8a77093be19e", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "QAing", - "number": 34925, - "repo": "iFixit/ifixit", - "user": "JRodger312", - "created_at": "2020-10-29T19:35:58.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "2c1e0b325b221799eecc245b359f8a77093be19e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "2c1e0b325b221799eecc245b359f8a77093be19e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 34925, "repo": "iFixit/ifixit", - "number": 34964, - "state": "open", - "title": "Quoted comments change quote-block visibility", - "body": "pull request dummy body", - "created_at": "2020-10-30T22:41:53.000Z", - "updated_at": "2020-10-30T22:41:53.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "guide-comments--change-quoteblock-visibility", + "user": "JRodger312", + "created_at": "2020-10-29T19:35:58.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 34964, + "state": "open", + "title": "Quoted comments change quote-block visibility", + "body": "pull request dummy body", + "created_at": "2020-10-30T22:41:53.000Z", + "updated_at": "2020-10-30T22:41:53.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "guide-comments--change-quoteblock-visibility", + "sha": "64bf41772407e98112f173eeb75b7118096203d1", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "ardelato" + }, + "cr_req": 2, + "requested_reviewers": [ + "danielbeardsley", + "hackalot805" + ], + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 34964, + "user": { + "id": 10889879, + "login": "addison-grant" + }, + "type": "dev_block", + "created_at": "2020-10-30T23:24:36.000Z", + "active": 1, + "comment_id": 719842423 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "64bf41772407e98112f173eeb75b7118096203d1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "64bf41772407e98112f173eeb75b7118096203d1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { "sha": "64bf41772407e98112f173eeb75b7118096203d1", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "ardelato" - }, - "cr_req": 2, - "requested_reviewers": ["danielbeardsley", "hackalot805"], - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 34964, - "user": { - "id": 10889879, - "login": "addison-grant" - }, - "type": "dev_block", - "created_at": "2020-10-30T23:24:36.000Z", - "active": 1, - "comment_id": 719842423 - } - } - ], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "64bf41772407e98112f173eeb75b7118096203d1", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "64bf41772407e98112f173eeb75b7118096203d1", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "64bf41772407e98112f173eeb75b7118096203d1", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35059, + "state": "open", + "title": "Make release-gate-sso-approvals-signoffs user editable", + "body": "pull request dummy body", + "created_at": "2020-11-10T00:46:05.000Z", + "updated_at": "2020-11-19T00:33:38.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "sso--saml2-signoff-setting-management", + "sha": "decdcfe240c9916c48be521e4420ca92e7cbd3c0", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "sso--change-default-sso-approvals-signoffs-setting" + }, + "user": { + "login": "rjmccluskey" + }, + "assignees": [ + "hackalot805", + "ardelato" + ], + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 41349604, + "login": "JRodger312" + }, + "type": "QA", + "created_at": "2020-11-20T21:42:20.000Z", + "active": 1, + "comment_id": 731422032 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-11-18T23:34:35.000Z", + "active": 1, + "comment_id": 730023937 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-18T21:48:32.000Z", + "active": 1, + "comment_id": 729977256 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 41349604, + "login": "JRodger312" + }, + "type": "QA", + "created_at": "2020-11-20T21:42:20.000Z", + "active": 1, + "comment_id": 731422032 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 41349604, + "login": "JRodger312" + }, + "type": "QA", + "created_at": "2020-11-17T16:54:42.000Z", + "active": 0, + "comment_id": 729060099 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 33426643, + "login": "k0rvusk0r4x" + }, + "type": "QA", + "created_at": "2020-11-11T19:08:36.000Z", + "active": 0, + "comment_id": 725606276 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-11-18T23:34:35.000Z", + "active": 1, + "comment_id": 730023937 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-18T21:48:32.000Z", + "active": 1, + "comment_id": 729977256 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-11-16T23:47:05.000Z", + "active": 0, + "comment_id": 728431243 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-13T16:38:20.000Z", + "active": 0, + "comment_id": 726866056 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-12T16:41:26.000Z", + "active": 0, + "comment_id": 726194620 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "deploy_block", + "created_at": "2020-11-10T00:46:31.000Z", + "active": 1, + "comment_id": 724374955 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "decdcfe240c9916c48be521e4420ca92e7cbd3c0", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "decdcfe240c9916c48be521e4420ca92e7cbd3c0", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "decdcfe240c9916c48be521e4420ca92e7cbd3c0", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35059, "repo": "iFixit/ifixit", + "user": "JRodger312", + "created_at": "2020-11-19T00:33:38.000Z" + }, + { + "title": "squad-application", "number": 35059, - "state": "open", - "title": "Make release-gate-sso-approvals-signoffs user editable", - "body": "pull request dummy body", - "created_at": "2020-11-10T00:46:05.000Z", - "updated_at": "2020-11-19T00:33:38.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "sso--saml2-signoff-setting-management", - "sha": "decdcfe240c9916c48be521e4420ca92e7cbd3c0", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "sso--change-default-sso-approvals-signoffs-setting" - }, - "user": { - "login": "rjmccluskey" - }, - "assignees": ["hackalot805", "ardelato"], - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35059, - "user": { - "id": 41349604, - "login": "JRodger312" - }, - "type": "QA", - "created_at": "2020-11-20T21:42:20.000Z", - "active": 1, - "comment_id": 731422032 - } - } - ], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35059, - "user": { - "id": 1248182, - "login": "cdcline" - }, - "type": "CR", - "created_at": "2020-11-18T23:34:35.000Z", - "active": 1, - "comment_id": 730023937 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35059, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-11-18T21:48:32.000Z", - "active": 1, - "comment_id": 729977256 - } - } - ], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35059, - "user": { - "id": 41349604, - "login": "JRodger312" - }, - "type": "QA", - "created_at": "2020-11-20T21:42:20.000Z", - "active": 1, - "comment_id": 731422032 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35059, - "user": { - "id": 41349604, - "login": "JRodger312" - }, - "type": "QA", - "created_at": "2020-11-17T16:54:42.000Z", - "active": 0, - "comment_id": 729060099 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35059, - "user": { - "id": 33426643, - "login": "k0rvusk0r4x" - }, - "type": "QA", - "created_at": "2020-11-11T19:08:36.000Z", - "active": 0, - "comment_id": 725606276 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35059, - "user": { - "id": 1248182, - "login": "cdcline" - }, - "type": "CR", - "created_at": "2020-11-18T23:34:35.000Z", - "active": 1, - "comment_id": 730023937 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35059, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-11-18T21:48:32.000Z", - "active": 1, - "comment_id": 729977256 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35059, - "user": { - "id": 1248182, - "login": "cdcline" - }, - "type": "CR", - "created_at": "2020-11-16T23:47:05.000Z", - "active": 0, - "comment_id": 728431243 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35059, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-11-13T16:38:20.000Z", - "active": 0, - "comment_id": 726866056 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35059, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-11-12T16:41:26.000Z", - "active": 0, - "comment_id": 726194620 - } - } - ], - "dev_block": [], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35059, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "deploy_block", - "created_at": "2020-11-10T00:46:31.000Z", - "active": 1, - "comment_id": 724374955 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "decdcfe240c9916c48be521e4420ca92e7cbd3c0", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "decdcfe240c9916c48be521e4420ca92e7cbd3c0", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "decdcfe240c9916c48be521e4420ca92e7cbd3c0", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "QAing", - "number": 35059, - "repo": "iFixit/ifixit", - "user": "JRodger312", - "created_at": "2020-11-19T00:33:38.000Z" - }, - { - "title": "squad-application", - "number": 35059, - "repo": "iFixit/ifixit", - "user": "raquelmsmith", - "created_at": "2020-11-13T22:56:45.000Z" - } - ] - }, - { "repo": "iFixit/ifixit", - "number": 35103, - "state": "open", - "title": "Add migration to flip default 'release-gate-sso-approvals-signoffs' setting", - "body": "pull request dummy body", - "created_at": "2020-11-12T22:46:37.000Z", - "updated_at": "2020-11-19T17:20:17.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "Dozuki - 12/01/2020", - "due_on": "2020-12-01T08:00:00.000Z" - }, - "head": { - "ref": "sso--change-default-sso-approvals-signoffs-setting", + "user": "raquelmsmith", + "created_at": "2020-11-13T22:56:45.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35103, + "state": "open", + "title": "Add migration to flip default 'release-gate-sso-approvals-signoffs' setting", + "body": "pull request dummy body", + "created_at": "2020-11-12T22:46:37.000Z", + "updated_at": "2020-11-19T17:20:17.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "Dozuki - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "sso--change-default-sso-approvals-signoffs-setting", + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "rjmccluskey" + }, + "cr_req": 2, + "qa_req": 0, + "status": { + "qa_req": 0, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-16T20:06:15.000Z", + "active": 1, + "comment_id": 728294783 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-13T16:36:35.000Z", + "active": 1, + "comment_id": 726865047 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-16T20:06:15.000Z", + "active": 1, + "comment_id": 728294783 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-13T16:36:35.000Z", + "active": 1, + "comment_id": 726865047 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "rjmccluskey" - }, - "cr_req": 2, - "qa_req": 0, - "status": { - "qa_req": 0, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35103, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "CR", - "created_at": "2020-11-16T20:06:15.000Z", - "active": 1, - "comment_id": 728294783 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35103, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-11-13T16:36:35.000Z", - "active": 1, - "comment_id": 726865047 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35103, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "CR", - "created_at": "2020-11-16T20:06:15.000Z", - "active": 1, - "comment_id": 728294783 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35103, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-11-13T16:36:35.000Z", - "active": 1, - "comment_id": 726865047 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "squad-application", - "number": 35103, - "repo": "iFixit/ifixit", - "user": "raquelmsmith", - "created_at": "2020-11-13T22:56:45.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "squad-application", + "number": 35103, "repo": "iFixit/ifixit", - "number": 351011, - "state": "open", - "title": "Add migration to flip default 'release-gate-sso-approvals-signoffs' setting", - "body": "pull request dummy body", - "created_at": "2020-11-12T22:46:37.000Z", - "updated_at": "2020-11-19T17:20:17.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "Dozuki - 12/01/2020", - "due_on": "2020-12-01T08:00:00.000Z" - }, - "head": { - "ref": "sso--change-default-sso-approvals-signoffs-setting", + "user": "raquelmsmith", + "created_at": "2020-11-13T22:56:45.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 351011, + "state": "open", + "title": "Add migration to flip default 'release-gate-sso-approvals-signoffs' setting", + "body": "pull request dummy body", + "created_at": "2020-11-12T22:46:37.000Z", + "updated_at": "2020-11-19T17:20:17.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "Dozuki - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "sso--change-default-sso-approvals-signoffs-setting", + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 2, + "qa_req": 0, + "status": { + "qa_req": 0, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-16T20:06:15.000Z", + "active": 1, + "comment_id": 728294783 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-13T16:36:35.000Z", + "active": 1, + "comment_id": 726865047 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-13T16:36:35.000Z", + "active": 1, + "comment_id": 726865047 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "danielbeardsley" - }, - "cr_req": 2, - "qa_req": 0, - "status": { - "qa_req": 0, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35103, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "CR", - "created_at": "2020-11-16T20:06:15.000Z", - "active": 1, - "comment_id": 728294783 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35103, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-11-13T16:36:35.000Z", - "active": 1, - "comment_id": 726865047 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35103, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-11-13T16:36:35.000Z", - "active": 1, - "comment_id": 726865047 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "squad-application", - "number": 35103, - "repo": "iFixit/ifixit", - "user": "raquelmsmith", - "created_at": "2020-11-13T22:56:45.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "squad-application", + "number": 35103, "repo": "iFixit/ifixit", - "number": 35129, - "state": "open", - "title": "Training Periods: Remove feature switch for training periods from the DB.", - "body": "pull request dummy body", - "created_at": "2020-11-16T23:32:47.000Z", - "updated_at": "2020-11-19T17:08:53.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "remove-from-db--feature-training-periods", + "user": "raquelmsmith", + "created_at": "2020-11-13T22:56:45.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35129, + "state": "open", + "title": "Training Periods: Remove feature switch for training periods from the DB.", + "body": "pull request dummy body", + "created_at": "2020-11-16T23:32:47.000Z", + "updated_at": "2020-11-19T17:08:53.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "remove-from-db--feature-training-periods", + "sha": "79c221f79a0b61952722cb25389c91ae4dafe340", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "hackalot805" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35129, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "dev_block", + "created_at": "2020-11-17T20:54:38.000Z", + "active": 1, + "comment_id": 729207978 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35129, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "deploy_block", + "created_at": "2020-11-16T23:33:14.000Z", + "active": 1, + "comment_id": 728408477 + } + } + ], + "commit_statuses": [ + { + "data": { "sha": "79c221f79a0b61952722cb25389c91ae4dafe340", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "hackalot805" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35129, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "dev_block", - "created_at": "2020-11-17T20:54:38.000Z", - "active": 1, - "comment_id": 729207978 - } - } - ], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35129, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "deploy_block", - "created_at": "2020-11-16T23:33:14.000Z", - "active": 1, - "comment_id": 728408477 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "79c221f79a0b61952722cb25389c91ae4dafe340", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "79c221f79a0b61952722cb25389c91ae4dafe340", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "79c221f79a0b61952722cb25389c91ae4dafe340", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35154, - "state": "open", - "title": "WDCloudSearchTest: Remove dependency on iFixit and parallelyze it", - "body": "pull request dummy body", - "created_at": "2020-11-18T07:56:39.000Z", - "updated_at": "2020-11-19T00:16:18.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "Dozuki - 12/01/2020", - "due_on": "2020-12-01T08:00:00.000Z" - }, - "head": { - "ref": "wdcloudsearchtest--remove-dependency-on-papa-fixit", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "79c221f79a0b61952722cb25389c91ae4dafe340", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "79c221f79a0b61952722cb25389c91ae4dafe340", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35154, + "state": "open", + "title": "WDCloudSearchTest: Remove dependency on iFixit and parallelyze it", + "body": "pull request dummy body", + "created_at": "2020-11-18T07:56:39.000Z", + "updated_at": "2020-11-19T00:16:18.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "Dozuki - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "wdcloudsearchtest--remove-dependency-on-papa-fixit", + "sha": "3e87476c711c3d3acd2828dbe604664754dc9e3e", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "BaseInfinity" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35154, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-18T17:11:41.000Z", + "active": 0, + "comment_id": 729821763 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35154, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "dev_block", + "created_at": "2020-11-18T18:43:50.000Z", + "active": 1, + "comment_id": 729879293 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35154, + "user": { + "id": 29667789, + "login": "mlahargou" + }, + "type": "dev_block", + "created_at": "2020-11-18T17:54:40.000Z", + "active": 1, + "comment_id": 729850478 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35154, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "deploy_block", + "created_at": "2020-11-18T17:11:41.000Z", + "active": 1, + "comment_id": 729821763 + } + } + ], + "commit_statuses": [ + { + "data": { "sha": "3e87476c711c3d3acd2828dbe604664754dc9e3e", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "BaseInfinity" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35154, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-11-18T17:11:41.000Z", - "active": 0, - "comment_id": 729821763 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35154, - "user": { - "id": 1424113, - "login": "BaseInfinity" - }, - "type": "dev_block", - "created_at": "2020-11-18T18:43:50.000Z", - "active": 1, - "comment_id": 729879293 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35154, - "user": { - "id": 29667789, - "login": "mlahargou" - }, - "type": "dev_block", - "created_at": "2020-11-18T17:54:40.000Z", - "active": 1, - "comment_id": 729850478 - } - } - ], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35154, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "deploy_block", - "created_at": "2020-11-18T17:11:41.000Z", - "active": 1, - "comment_id": 729821763 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "3e87476c711c3d3acd2828dbe604664754dc9e3e", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "3e87476c711c3d3acd2828dbe604664754dc9e3e", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "3e87476c711c3d3acd2828dbe604664754dc9e3e", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35168, - "state": "open", - "title": "Shopify Product Export: Handle wiki links per store", - "body": "pull request dummy body", - "created_at": "2020-11-19T16:46:00.000Z", - "updated_at": "2020-11-24T23:04:02.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "iFixit - 12/01/2020", - "due_on": "2020-12-01T08:00:00.000Z" - }, - "head": { - "ref": "shopify-product-export--render-wikis-per-store", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "3e87476c711c3d3acd2828dbe604664754dc9e3e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "3e87476c711c3d3acd2828dbe604664754dc9e3e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35168, + "state": "open", + "title": "Shopify Product Export: Handle wiki links per store", + "body": "pull request dummy body", + "created_at": "2020-11-19T16:46:00.000Z", + "updated_at": "2020-11-24T23:04:02.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "shopify-product-export--render-wikis-per-store", + "sha": "b7794fc7bd686810c0a2a44372b61673de7ab26f", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "jyee27" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35168, + "user": { + "id": 589425, + "login": "sterlinghirsh" + }, + "type": "CR", + "created_at": "2020-11-20T10:37:41.000Z", + "active": 0, + "comment_id": 731089968 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35168, + "user": { + "id": 589425, + "login": "sterlinghirsh" + }, + "type": "dev_block", + "created_at": "2020-11-20T10:37:41.000Z", + "active": 1, + "comment_id": 731089968 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "b7794fc7bd686810c0a2a44372b61673de7ab26f", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "jyee27" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35168, - "user": { - "id": 589425, - "login": "sterlinghirsh" - }, - "type": "CR", - "created_at": "2020-11-20T10:37:41.000Z", - "active": 0, - "comment_id": 731089968 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35168, - "user": { - "id": 589425, - "login": "sterlinghirsh" - }, - "type": "dev_block", - "created_at": "2020-11-20T10:37:41.000Z", - "active": 1, - "comment_id": 731089968 - } - } - ], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "b7794fc7bd686810c0a2a44372b61673de7ab26f", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "b7794fc7bd686810c0a2a44372b61673de7ab26f", - "target_url": "https://wwww.example.com", - "description": "Build failure", - "state": "failure", - "context": "phpunit" - } - }, - { - "data": { - "sha": "b7794fc7bd686810c0a2a44372b61673de7ab26f", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35177, - "state": "open", - "title": "Shipping Carriers: Allow querying shelf weight", - "body": "pull request dummy body", - "created_at": "2020-11-19T20:00:17.000Z", - "updated_at": "2020-11-30T22:08:10.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "iFixit - 12/01/2020", - "due_on": "2020-12-01T08:00:00.000Z" - }, - "head": { - "ref": "cart--shipping--query-carriers-with-shelf-weight", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "b7794fc7bd686810c0a2a44372b61673de7ab26f", + "target_url": "https://wwww.example.com", + "description": "Build failure", + "state": "failure", + "context": "phpunit" + } + }, + { + "data": { + "sha": "b7794fc7bd686810c0a2a44372b61673de7ab26f", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35177, + "state": "open", + "title": "Shipping Carriers: Allow querying shelf weight", + "body": "pull request dummy body", + "created_at": "2020-11-19T20:00:17.000Z", + "updated_at": "2020-11-30T22:08:10.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "cart--shipping--query-carriers-with-shelf-weight", + "sha": "fb3a93c57f06304cf108ae431800f99dfffd02d3", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "cdcline" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35177, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-11-19T20:28:20.000Z", + "active": 0, + "comment_id": 730618736 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35177, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "dev_block", + "created_at": "2020-11-19T20:00:45.000Z", + "active": 1, + "comment_id": 730604328 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "fb3a93c57f06304cf108ae431800f99dfffd02d3", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "cdcline" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35177, - "user": { - "id": 857362, - "login": "jarstelfox" - }, - "type": "CR", - "created_at": "2020-11-19T20:28:20.000Z", - "active": 0, - "comment_id": 730618736 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35177, - "user": { - "id": 1248182, - "login": "cdcline" - }, - "type": "dev_block", - "created_at": "2020-11-19T20:00:45.000Z", - "active": 1, - "comment_id": 730604328 - } - } - ], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "fb3a93c57f06304cf108ae431800f99dfffd02d3", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "fb3a93c57f06304cf108ae431800f99dfffd02d3", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "fb3a93c57f06304cf108ae431800f99dfffd02d3", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35194, - "state": "open", - "title": "Valkyrie: Allow /Page/all-posts/page/2 type urls", - "body": "pull request dummy body", - "created_at": "2020-11-20T18:46:43.000Z", - "updated_at": "2020-11-30T23:12:06.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "valkyrie-allow-long-page-routes", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "fb3a93c57f06304cf108ae431800f99dfffd02d3", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "fb3a93c57f06304cf108ae431800f99dfffd02d3", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35194, + "state": "open", + "title": "Valkyrie: Allow /Page/all-posts/page/2 type urls", + "body": "pull request dummy body", + "created_at": "2020-11-20T18:46:43.000Z", + "updated_at": "2020-11-30T23:12:06.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "valkyrie-allow-long-page-routes", + "sha": "69ba02129aa88ef73f59d37d83660a3d180db4da", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "jarstelfox" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35194, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-11-30T23:12:00.000Z", + "active": 1, + "comment_id": 736111068 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35194, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-11-30T23:12:00.000Z", + "active": 1, + "comment_id": 736111068 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35194, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-11-20T19:51:53.000Z", + "active": 0, + "comment_id": 731375766 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "69ba02129aa88ef73f59d37d83660a3d180db4da", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "jarstelfox" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35194, - "user": { - "id": 58952979, - "login": "batbattur" - }, - "type": "QA", - "created_at": "2020-11-30T23:12:00.000Z", - "active": 1, - "comment_id": 736111068 - } - } - ], - "CR": [], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35194, - "user": { - "id": 58952979, - "login": "batbattur" - }, - "type": "QA", - "created_at": "2020-11-30T23:12:00.000Z", - "active": 1, - "comment_id": 736111068 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35194, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "CR", - "created_at": "2020-11-20T19:51:53.000Z", - "active": 0, - "comment_id": 731375766 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "69ba02129aa88ef73f59d37d83660a3d180db4da", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "69ba02129aa88ef73f59d37d83660a3d180db4da", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "69ba02129aa88ef73f59d37d83660a3d180db4da", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35207, - "state": "open", - "title": "Update core-primitives to 1.2.0 from 0.12.1", - "body": "pull request dummy body", - "created_at": "2020-11-22T21:30:22.000Z", - "updated_at": "2020-12-21T23:17:15.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "core-primitives--update-to-1-1-1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "69ba02129aa88ef73f59d37d83660a3d180db4da", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "69ba02129aa88ef73f59d37d83660a3d180db4da", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35207, + "state": "open", + "title": "Update core-primitives to 1.2.0 from 0.12.1", + "body": "pull request dummy body", + "created_at": "2020-11-22T21:30:22.000Z", + "updated_at": "2020-12-21T23:17:15.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "core-primitives--update-to-1-1-1", + "sha": "a864b1fe5efdcc9c08c3553c55e896477b74b34b", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "addison-grant" + }, + "cr_req": 3, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 3, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35207, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-21T23:17:15.000Z", + "active": 1, + "comment_id": 749249097 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35207, + "user": { + "id": 1634505, + "login": "ianrohde" + }, + "type": "CR", + "created_at": "2020-12-10T00:20:40.000Z", + "active": 1, + "comment_id": 742147618 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35207, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-09T23:50:07.000Z", + "active": 1, + "comment_id": 742137133 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35207, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-21T23:17:15.000Z", + "active": 1, + "comment_id": 749249097 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35207, + "user": { + "id": 1634505, + "login": "ianrohde" + }, + "type": "CR", + "created_at": "2020-12-10T00:20:40.000Z", + "active": 1, + "comment_id": 742147618 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35207, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-09T23:50:07.000Z", + "active": 1, + "comment_id": 742137133 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "a864b1fe5efdcc9c08c3553c55e896477b74b34b", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { "sha": "a864b1fe5efdcc9c08c3553c55e896477b74b34b", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "addison-grant" - }, - "cr_req": 3, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 3, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35207, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-21T23:17:15.000Z", - "active": 1, - "comment_id": 749249097 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35207, - "user": { - "id": 1634505, - "login": "ianrohde" - }, - "type": "CR", - "created_at": "2020-12-10T00:20:40.000Z", - "active": 1, - "comment_id": 742147618 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35207, - "user": { - "id": 24502905, - "login": "bhuminson" - }, - "type": "CR", - "created_at": "2020-12-09T23:50:07.000Z", - "active": 1, - "comment_id": 742137133 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35207, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-21T23:17:15.000Z", - "active": 1, - "comment_id": 749249097 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35207, - "user": { - "id": 1634505, - "login": "ianrohde" - }, - "type": "CR", - "created_at": "2020-12-10T00:20:40.000Z", - "active": 1, - "comment_id": 742147618 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35207, - "user": { - "id": 24502905, - "login": "bhuminson" - }, - "type": "CR", - "created_at": "2020-12-09T23:50:07.000Z", - "active": 1, - "comment_id": 742137133 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "a864b1fe5efdcc9c08c3553c55e896477b74b34b", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "a864b1fe5efdcc9c08c3553c55e896477b74b34b", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "a864b1fe5efdcc9c08c3553c55e896477b74b34b", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "external_block", - "number": 35207, - "repo": "iFixit/ifixit", - "user": "addison-grant", - "created_at": "2020-11-24T18:46:54.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "a864b1fe5efdcc9c08c3553c55e896477b74b34b", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "external_block", + "number": 35207, "repo": "iFixit/ifixit", - "number": 35236, - "state": "open", - "title": "Add \"GUIDE_ARCHIVED\" flag to full width flags", - "body": "pull request dummy body", - "created_at": "2020-11-25T18:37:50.000Z", - "updated_at": "2020-11-25T23:56:00.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "flags--archived-major-flag", + "user": "addison-grant", + "created_at": "2020-11-24T18:46:54.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35236, + "state": "open", + "title": "Add \"GUIDE_ARCHIVED\" flag to full width flags", + "body": "pull request dummy body", + "created_at": "2020-11-25T18:37:50.000Z", + "updated_at": "2020-11-25T23:56:00.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "flags--archived-major-flag", + "sha": "d68b37267345be9433fc5d2147010be2d3272261", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "rjmccluskey" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35236, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "QA", + "created_at": "2020-11-25T23:55:49.000Z", + "active": 1, + "comment_id": 733996627 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35236, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "QA", + "created_at": "2020-11-25T23:55:49.000Z", + "active": 1, + "comment_id": 733996627 + } + } + ], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "d68b37267345be9433fc5d2147010be2d3272261", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "rjmccluskey" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35236, - "user": { - "id": 22064420, - "login": "ardelato" - }, - "type": "QA", - "created_at": "2020-11-25T23:55:49.000Z", - "active": 1, - "comment_id": 733996627 - } - } - ], - "CR": [], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35236, - "user": { - "id": 22064420, - "login": "ardelato" - }, - "type": "QA", - "created_at": "2020-11-25T23:55:49.000Z", - "active": 1, - "comment_id": 733996627 - } - } - ], - "allCR": [], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "d68b37267345be9433fc5d2147010be2d3272261", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "d68b37267345be9433fc5d2147010be2d3272261", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "d68b37267345be9433fc5d2147010be2d3272261", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35249, - "state": "open", - "title": "Ready pull that has no CI and should be in Deploy Block column", - "body": "pull request dummy body", - "created_at": "2020-11-30T20:28:50.000Z", - "updated_at": "2020-12-01T15:50:31.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "iFixit - 12/01/2020", - "due_on": "2020-12-01T08:00:00.000Z" - }, - "head": { - "ref": "product-search-improvements--manual-search-keyword-attributes", - "sha": "86b6d9cd5fdb231a6e2c5249aaa745b7428a981c", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "evannoronha" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35258, - "user": { - "id": 58952979, - "login": "batbattur" - }, - "type": "QA", - "created_at": "2020-12-01T01:02:51.000Z", - "active": 1, - "comment_id": 736148899 - } - } - ], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35249, - "user": { - "id": 857362, - "login": "jarstelfox" - }, - "type": "CR", - "created_at": "2020-12-01T09:38:01.000Z", - "active": 1, - "comment_id": 736350879 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35249, - "user": { - "id": 194351, - "login": "sctice" - }, - "type": "CR", - "created_at": "2020-11-30T23:28:15.000Z", - "active": 1, - "comment_id": 736116927 - } - } - ], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35258, - "user": { - "id": 58952979, - "login": "batbattur" - }, - "type": "QA", - "created_at": "2020-12-01T01:02:51.000Z", - "active": 1, - "comment_id": 736148899 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35249, - "user": { - "id": 857362, - "login": "jarstelfox" - }, - "type": "CR", - "created_at": "2020-12-01T09:38:01.000Z", - "active": 1, - "comment_id": 736350879 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35249, - "user": { - "id": 194351, - "login": "sctice" - }, - "type": "CR", - "created_at": "2020-11-30T23:28:15.000Z", - "active": 1, - "comment_id": 736116927 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35249, - "user": { - "id": 194351, - "login": "sctice" - }, - "type": "CR", - "created_at": "2020-11-30T22:29:19.000Z", - "active": 0, - "comment_id": 736094713 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35252, - "state": "open", - "title": "Store dropdown: display US on US", - "body": "pull request dummy body", - "created_at": "2020-11-30T21:28:48.000Z", - "updated_at": "2020-12-01T17:12:16.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "iFixit - 12/01/2020", - "due_on": "2020-12-01T08:00:00.000Z" - }, - "head": { - "ref": "store-dropdown--display-us-on-us", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "d68b37267345be9433fc5d2147010be2d3272261", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "d68b37267345be9433fc5d2147010be2d3272261", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35249, + "state": "open", + "title": "Ready pull that has no CI and should be in Deploy Block column", + "body": "pull request dummy body", + "created_at": "2020-11-30T20:28:50.000Z", + "updated_at": "2020-12-01T15:50:31.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "product-search-improvements--manual-search-keyword-attributes", + "sha": "86b6d9cd5fdb231a6e2c5249aaa745b7428a981c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "evannoronha" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-01T01:02:51.000Z", + "active": 1, + "comment_id": 736148899 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35249, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-12-01T09:38:01.000Z", + "active": 1, + "comment_id": 736350879 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35249, + "user": { + "id": 194351, + "login": "sctice" + }, + "type": "CR", + "created_at": "2020-11-30T23:28:15.000Z", + "active": 1, + "comment_id": 736116927 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-01T01:02:51.000Z", + "active": 1, + "comment_id": 736148899 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35249, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-12-01T09:38:01.000Z", + "active": 1, + "comment_id": 736350879 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35249, + "user": { + "id": 194351, + "login": "sctice" + }, + "type": "CR", + "created_at": "2020-11-30T23:28:15.000Z", + "active": 1, + "comment_id": 736116927 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35249, + "user": { + "id": 194351, + "login": "sctice" + }, + "type": "CR", + "created_at": "2020-11-30T22:29:19.000Z", + "active": 0, + "comment_id": 736094713 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35252, + "state": "open", + "title": "Store dropdown: display US on US", + "body": "pull request dummy body", + "created_at": "2020-11-30T21:28:48.000Z", + "updated_at": "2020-12-01T17:12:16.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "store-dropdown--display-us-on-us", + "sha": "34d4823d9a00c7f1c7a273850b01f03add40204c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "zdmitchell" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35252, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-01T17:24:33.000Z", + "active": 1, + "comment_id": 736699963 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35252, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-01T17:24:33.000Z", + "active": 1, + "comment_id": 736699963 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "34d4823d9a00c7f1c7a273850b01f03add40204c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { "sha": "34d4823d9a00c7f1c7a273850b01f03add40204c", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "zdmitchell" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35252, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-12-01T17:24:33.000Z", - "active": 1, - "comment_id": 736699963 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35252, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-12-01T17:24:33.000Z", - "active": 1, - "comment_id": 736699963 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "34d4823d9a00c7f1c7a273850b01f03add40204c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "34d4823d9a00c7f1c7a273850b01f03add40204c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "34d4823d9a00c7f1c7a273850b01f03add40204c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "QAing", - "number": 35252, - "repo": "iFixit/ifixit", - "user": "ardelato", - "created_at": "2020-12-01T17:12:16.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "34d4823d9a00c7f1c7a273850b01f03add40204c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35252, "repo": "iFixit/ifixit", - "number": 35255, - "state": "open", - "title": "WDWarehouseTransferTest: Deflake and robustify ", - "body": "pull request dummy body", - "created_at": "2020-11-30T22:27:24.000Z", - "updated_at": "2020-12-01T16:45:52.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "tests--deflake-warehouse-transfer-test", + "user": "ardelato", + "created_at": "2020-12-01T17:12:16.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35255, + "state": "open", + "title": "WDWarehouseTransferTest: Deflake and robustify ", + "body": "pull request dummy body", + "created_at": "2020-11-30T22:27:24.000Z", + "updated_at": "2020-12-01T16:45:52.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "tests--deflake-warehouse-transfer-test", + "sha": "eba3c6a5c9f1d691490a79eab8ec1a1df166698c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "mlahargou" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35255, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-12-01T09:43:22.000Z", + "active": 1, + "comment_id": 736358617 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35255, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-11-30T23:31:37.000Z", + "active": 1, + "comment_id": 736118110 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35255, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-12-01T09:43:22.000Z", + "active": 1, + "comment_id": 736358617 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35255, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-11-30T23:31:37.000Z", + "active": 1, + "comment_id": 736118110 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35255, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-11-30T22:59:13.000Z", + "active": 0, + "comment_id": 736106240 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "eba3c6a5c9f1d691490a79eab8ec1a1df166698c", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "mlahargou" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35255, - "user": { - "id": 857362, - "login": "jarstelfox" - }, - "type": "CR", - "created_at": "2020-12-01T09:43:22.000Z", - "active": 1, - "comment_id": 736358617 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35255, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-11-30T23:31:37.000Z", - "active": 1, - "comment_id": 736118110 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35255, - "user": { - "id": 857362, - "login": "jarstelfox" - }, - "type": "CR", - "created_at": "2020-12-01T09:43:22.000Z", - "active": 1, - "comment_id": 736358617 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35255, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-11-30T23:31:37.000Z", - "active": 1, - "comment_id": 736118110 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35255, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-11-30T22:59:13.000Z", - "active": 0, - "comment_id": 736106240 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "eba3c6a5c9f1d691490a79eab8ec1a1df166698c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "eba3c6a5c9f1d691490a79eab8ec1a1df166698c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "eba3c6a5c9f1d691490a79eab8ec1a1df166698c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "QAing", - "number": 35255, - "repo": "iFixit/ifixit", - "user": "k0rvusk0r4x", - "created_at": "2020-12-01T16:45:52.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "eba3c6a5c9f1d691490a79eab8ec1a1df166698c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "eba3c6a5c9f1d691490a79eab8ec1a1df166698c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35255, "repo": "iFixit/ifixit", - "number": 35256, - "state": "open", - "title": "Add rel=\"noopener noreferrer\" to some external links", - "body": "pull request dummy body", - "created_at": "2020-11-30T22:54:48.000Z", - "updated_at": "2020-12-01T16:51:37.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "html--add-rel-noopener-noreferrer", + "user": "k0rvusk0r4x", + "created_at": "2020-12-01T16:45:52.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35256, + "state": "open", + "title": "Add rel=\"noopener noreferrer\" to some external links", + "body": "pull request dummy body", + "created_at": "2020-11-30T22:54:48.000Z", + "updated_at": "2020-12-01T16:51:37.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "html--add-rel-noopener-noreferrer", + "sha": "f9395a5e2fc082c49b99cbcaca8d2b074aed2829", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "scbarber" + }, + "cr_req": 3, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 3, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-11-30T23:29:44.000Z", + "active": 1, + "comment_id": 736117456 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-11-30T23:19:47.000Z", + "active": 1, + "comment_id": 736113907 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 3336250, + "login": "caphene" + }, + "type": "CR", + "created_at": "2020-11-30T23:18:42.000Z", + "active": 1, + "comment_id": 736113458 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 1634505, + "login": "ianrohde" + }, + "type": "CR", + "created_at": "2020-11-30T23:15:11.000Z", + "active": 1, + "comment_id": 736112239 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-11-30T23:05:09.000Z", + "active": 1, + "comment_id": 736108576 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-11-30T23:29:44.000Z", + "active": 1, + "comment_id": 736117456 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-11-30T23:19:47.000Z", + "active": 1, + "comment_id": 736113907 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 3336250, + "login": "caphene" + }, + "type": "CR", + "created_at": "2020-11-30T23:18:42.000Z", + "active": 1, + "comment_id": 736113458 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 1634505, + "login": "ianrohde" + }, + "type": "CR", + "created_at": "2020-11-30T23:15:11.000Z", + "active": 1, + "comment_id": 736112239 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-11-30T23:05:09.000Z", + "active": 1, + "comment_id": 736108576 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "f9395a5e2fc082c49b99cbcaca8d2b074aed2829", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "scbarber" - }, - "cr_req": 3, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 3, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35256, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-11-30T23:29:44.000Z", - "active": 1, - "comment_id": 736117456 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35256, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "CR", - "created_at": "2020-11-30T23:19:47.000Z", - "active": 1, - "comment_id": 736113907 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35256, - "user": { - "id": 3336250, - "login": "caphene" - }, - "type": "CR", - "created_at": "2020-11-30T23:18:42.000Z", - "active": 1, - "comment_id": 736113458 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35256, - "user": { - "id": 1634505, - "login": "ianrohde" - }, - "type": "CR", - "created_at": "2020-11-30T23:15:11.000Z", - "active": 1, - "comment_id": 736112239 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35256, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "CR", - "created_at": "2020-11-30T23:05:09.000Z", - "active": 1, - "comment_id": 736108576 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35256, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-11-30T23:29:44.000Z", - "active": 1, - "comment_id": 736117456 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35256, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "CR", - "created_at": "2020-11-30T23:19:47.000Z", - "active": 1, - "comment_id": 736113907 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35256, - "user": { - "id": 3336250, - "login": "caphene" - }, - "type": "CR", - "created_at": "2020-11-30T23:18:42.000Z", - "active": 1, - "comment_id": 736113458 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35256, - "user": { - "id": 1634505, - "login": "ianrohde" - }, - "type": "CR", - "created_at": "2020-11-30T23:15:11.000Z", - "active": 1, - "comment_id": 736112239 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35256, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "CR", - "created_at": "2020-11-30T23:05:09.000Z", - "active": 1, - "comment_id": 736108576 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "f9395a5e2fc082c49b99cbcaca8d2b074aed2829", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "f9395a5e2fc082c49b99cbcaca8d2b074aed2829", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "f9395a5e2fc082c49b99cbcaca8d2b074aed2829", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "QAing", - "number": 35256, - "repo": "iFixit/ifixit", - "user": "batbattur", - "created_at": "2020-12-01T16:51:37.000Z" - }, - { - "title": "security", - "number": 35256, - "repo": "iFixit/ifixit", - "user": "scbarber", - "created_at": "2020-11-30T22:54:48.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "f9395a5e2fc082c49b99cbcaca8d2b074aed2829", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "f9395a5e2fc082c49b99cbcaca8d2b074aed2829", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35256, "repo": "iFixit/ifixit", - "number": 35258, - "state": "open", - "title": "WDAddressVerificationTest: Ensure button is still", - "body": "pull request dummy body", - "created_at": "2020-11-30T23:24:22.000Z", - "updated_at": "2020-11-30T23:24:22.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "tests--deflake-wd-address-verification-test-2", - "sha": "f7b399029f0fddf893560e630b761a1b0cea1def", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "mlahargou" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35258, - "user": { - "id": 58952979, - "login": "batbattur" - }, - "type": "QA", - "created_at": "2020-12-01T01:02:51.000Z", - "active": 1, - "comment_id": 736148899 - } - } - ], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35258, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "CR", - "created_at": "2020-12-01T16:24:05.000Z", - "active": 1, - "comment_id": 736660821 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35258, - "user": { - "id": 857362, - "login": "jarstelfox" - }, - "type": "CR", - "created_at": "2020-12-01T09:41:33.000Z", - "active": 1, - "comment_id": 736356655 - } - } - ], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35258, - "user": { - "id": 58952979, - "login": "batbattur" - }, - "type": "QA", - "created_at": "2020-12-01T01:02:51.000Z", - "active": 1, - "comment_id": 736148899 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35258, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "CR", - "created_at": "2020-12-01T16:24:05.000Z", - "active": 1, - "comment_id": 736660821 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35258, - "user": { - "id": 857362, - "login": "jarstelfox" - }, - "type": "CR", - "created_at": "2020-12-01T09:41:33.000Z", - "active": 1, - "comment_id": 736356655 - } - } - ], - "dev_block": [], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35258, - "user": { - "id": 857362, - "login": "jarstelfox" - }, - "type": "deploy_block", - "created_at": "2020-12-01T09:41:33.000Z", - "active": 1, - "comment_id": 736356655 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "f7b399029f0fddf893560e630b761a1b0cea1def", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "f7b399029f0fddf893560e630b761a1b0cea1def", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "f7b399029f0fddf893560e630b761a1b0cea1def", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { + "user": "batbattur", + "created_at": "2020-12-01T16:51:37.000Z" + }, + { + "title": "security", + "number": 35256, "repo": "iFixit/ifixit", - "number": 35261, - "state": "open", - "title": "Verify Card: remove unused, broken tool", - "body": "pull request dummy body", - "created_at": "2020-12-01T00:16:29.000Z", - "updated_at": "2020-12-22T22:47:11.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "security--sanitize-file-upload", + "user": "scbarber", + "created_at": "2020-11-30T22:54:48.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35258, + "state": "open", + "title": "WDAddressVerificationTest: Ensure button is still", + "body": "pull request dummy body", + "created_at": "2020-11-30T23:24:22.000Z", + "updated_at": "2020-11-30T23:24:22.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "tests--deflake-wd-address-verification-test-2", + "sha": "f7b399029f0fddf893560e630b761a1b0cea1def", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "mlahargou" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-01T01:02:51.000Z", + "active": 1, + "comment_id": 736148899 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-12-01T16:24:05.000Z", + "active": 1, + "comment_id": 736660821 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-12-01T09:41:33.000Z", + "active": 1, + "comment_id": 736356655 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-01T01:02:51.000Z", + "active": 1, + "comment_id": 736148899 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-12-01T16:24:05.000Z", + "active": 1, + "comment_id": 736660821 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-12-01T09:41:33.000Z", + "active": 1, + "comment_id": 736356655 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "deploy_block", + "created_at": "2020-12-01T09:41:33.000Z", + "active": 1, + "comment_id": 736356655 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "f7b399029f0fddf893560e630b761a1b0cea1def", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "f7b399029f0fddf893560e630b761a1b0cea1def", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "f7b399029f0fddf893560e630b761a1b0cea1def", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35261, + "state": "open", + "title": "Verify Card: remove unused, broken tool", + "body": "pull request dummy body", + "created_at": "2020-12-01T00:16:29.000Z", + "updated_at": "2020-12-22T22:47:11.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "security--sanitize-file-upload", + "sha": "3510dbb8a56227c9e43a1bc7cfcac736ccf2e8b5", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "scbarber" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35261, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-12-01T15:46:05.000Z", + "active": 0, + "comment_id": 736637185 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35261, + "user": { + "id": 3336250, + "login": "caphene" + }, + "type": "CR", + "created_at": "2020-12-01T00:21:29.000Z", + "active": 0, + "comment_id": 736134645 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "3510dbb8a56227c9e43a1bc7cfcac736ccf2e8b5", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { "sha": "3510dbb8a56227c9e43a1bc7cfcac736ccf2e8b5", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "scbarber" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35261, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "CR", - "created_at": "2020-12-01T15:46:05.000Z", - "active": 0, - "comment_id": 736637185 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35261, - "user": { - "id": 3336250, - "login": "caphene" - }, - "type": "CR", - "created_at": "2020-12-01T00:21:29.000Z", - "active": 0, - "comment_id": 736134645 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "3510dbb8a56227c9e43a1bc7cfcac736ccf2e8b5", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "3510dbb8a56227c9e43a1bc7cfcac736ccf2e8b5", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "3510dbb8a56227c9e43a1bc7cfcac736ccf2e8b5", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "security", - "number": 35261, - "repo": "iFixit/ifixit", - "user": "scbarber", - "created_at": "2020-12-01T00:16:29.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "3510dbb8a56227c9e43a1bc7cfcac736ccf2e8b5", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "security", + "number": 35261, "repo": "iFixit/ifixit", - "number": 35263, - "state": "open", - "title": "Grafana: Add a custom Parts and Tools dashboard for customer.", - "body": "pull request dummy body", - "created_at": "2020-12-01T00:59:10.000Z", - "updated_at": "2020-12-01T17:05:01.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "grafana--add-custom-dashboard", + "user": "scbarber", + "created_at": "2020-12-01T00:16:29.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35263, + "state": "open", + "title": "Grafana: Add a custom Parts and Tools dashboard for customer.", + "body": "pull request dummy body", + "created_at": "2020-12-01T00:59:10.000Z", + "updated_at": "2020-12-01T17:05:01.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "grafana--add-custom-dashboard", + "sha": "1291c5adfd85433c17ba68b6acb33510edaf7418", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "sivadnor" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "1291c5adfd85433c17ba68b6acb33510edaf7418", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { "sha": "1291c5adfd85433c17ba68b6acb33510edaf7418", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "sivadnor" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "1291c5adfd85433c17ba68b6acb33510edaf7418", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "1291c5adfd85433c17ba68b6acb33510edaf7418", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "1291c5adfd85433c17ba68b6acb33510edaf7418", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "QAing", - "number": 35263, - "repo": "iFixit/ifixit", - "user": "ardelato", - "created_at": "2020-12-01T17:05:01.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "1291c5adfd85433c17ba68b6acb33510edaf7418", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35263, "repo": "iFixit/ifixit", - "number": 35272, - "state": "open", - "title": "Manifesto: Add Danish manifesto", - "body": "pull request dummy body", - "created_at": "2020-12-01T17:17:37.000Z", - "updated_at": "2020-12-01T17:29:48.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "Backlog", - "due_on": null - }, - "head": { - "ref": "manifesto--add-danish-manifesto", + "user": "ardelato", + "created_at": "2020-12-01T17:05:01.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35272, + "state": "open", + "title": "Manifesto: Add Danish manifesto", + "body": "pull request dummy body", + "created_at": "2020-12-01T17:17:37.000Z", + "updated_at": "2020-12-01T17:29:48.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "Backlog", + "due_on": null + }, + "head": { + "ref": "manifesto--add-danish-manifesto", + "sha": "34686acedd0bc6bdd610c7ce634b1d8f36e87733", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "zdmitchell" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 55823474, + "login": "emmalopezcode" + }, + "type": "CR", + "created_at": "2020-12-01T17:29:48.000Z", + "active": 1, + "comment_id": 736703181 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-01T17:29:15.000Z", + "active": 1, + "comment_id": 736702860 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 22163939, + "login": "GrantMatejka" + }, + "type": "CR", + "created_at": "2020-12-01T17:28:28.000Z", + "active": 1, + "comment_id": 736702388 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 52104630, + "login": "masonmcelvain" + }, + "type": "CR", + "created_at": "2020-12-01T17:28:20.000Z", + "active": 1, + "comment_id": 736702305 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 55823474, + "login": "emmalopezcode" + }, + "type": "CR", + "created_at": "2020-12-01T17:29:48.000Z", + "active": 1, + "comment_id": 736703181 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-01T17:29:15.000Z", + "active": 1, + "comment_id": 736702860 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 22163939, + "login": "GrantMatejka" + }, + "type": "CR", + "created_at": "2020-12-01T17:28:28.000Z", + "active": 1, + "comment_id": 736702388 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 52104630, + "login": "masonmcelvain" + }, + "type": "CR", + "created_at": "2020-12-01T17:28:20.000Z", + "active": 1, + "comment_id": 736702305 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "34686acedd0bc6bdd610c7ce634b1d8f36e87733", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "zdmitchell" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35272, - "user": { - "id": 55823474, - "login": "emmalopezcode" - }, - "type": "CR", - "created_at": "2020-12-01T17:29:48.000Z", - "active": 1, - "comment_id": 736703181 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35272, - "user": { - "id": 24502905, - "login": "bhuminson" - }, - "type": "CR", - "created_at": "2020-12-01T17:29:15.000Z", - "active": 1, - "comment_id": 736702860 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35272, - "user": { - "id": 22163939, - "login": "GrantMatejka" - }, - "type": "CR", - "created_at": "2020-12-01T17:28:28.000Z", - "active": 1, - "comment_id": 736702388 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35272, - "user": { - "id": 52104630, - "login": "masonmcelvain" - }, - "type": "CR", - "created_at": "2020-12-01T17:28:20.000Z", - "active": 1, - "comment_id": 736702305 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35272, - "user": { - "id": 55823474, - "login": "emmalopezcode" - }, - "type": "CR", - "created_at": "2020-12-01T17:29:48.000Z", - "active": 1, - "comment_id": 736703181 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35272, - "user": { - "id": 24502905, - "login": "bhuminson" - }, - "type": "CR", - "created_at": "2020-12-01T17:29:15.000Z", - "active": 1, - "comment_id": 736702860 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35272, - "user": { - "id": 22163939, - "login": "GrantMatejka" - }, - "type": "CR", - "created_at": "2020-12-01T17:28:28.000Z", - "active": 1, - "comment_id": 736702388 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35272, - "user": { - "id": 52104630, - "login": "masonmcelvain" - }, - "type": "CR", - "created_at": "2020-12-01T17:28:20.000Z", - "active": 1, - "comment_id": 736702305 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "34686acedd0bc6bdd610c7ce634b1d8f36e87733", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "34686acedd0bc6bdd610c7ce634b1d8f36e87733", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "34686acedd0bc6bdd610c7ce634b1d8f36e87733", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35319, - "state": "open", - "title": "MySQL 8: commit schema for all our DBs", - "body": "pull request dummy body", - "created_at": "2020-12-03T18:46:43.000Z", - "updated_at": "2020-12-21T22:54:28.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "mysql8--commit-schema-diff", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "34686acedd0bc6bdd610c7ce634b1d8f36e87733", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "34686acedd0bc6bdd610c7ce634b1d8f36e87733", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35319, + "state": "open", + "title": "MySQL 8: commit schema for all our DBs", + "body": "pull request dummy body", + "created_at": "2020-12-03T18:46:43.000Z", + "updated_at": "2020-12-21T22:54:28.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "mysql8--commit-schema-diff", + "sha": "0e5e559c7e3bb513a7262168ed2aadccfbb029d4", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 3, + "qa_req": 0, + "status": { + "qa_req": 0, + "cr_req": 3, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35319, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-21T22:54:08.000Z", + "active": 1, + "comment_id": 749241714 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35319, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-21T22:54:08.000Z", + "active": 1, + "comment_id": 749241714 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35319, + "user": { + "id": 22163939, + "login": "GrantMatejka" + }, + "type": "CR", + "created_at": "2020-12-09T23:29:03.000Z", + "active": 0, + "comment_id": 742129032 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35319, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-08T01:13:04.000Z", + "active": 0, + "comment_id": 740288944 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "0e5e559c7e3bb513a7262168ed2aadccfbb029d4", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "danielbeardsley" - }, - "cr_req": 3, - "qa_req": 0, - "status": { - "qa_req": 0, - "cr_req": 3, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35319, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "CR", - "created_at": "2020-12-21T22:54:08.000Z", - "active": 1, - "comment_id": 749241714 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35319, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "CR", - "created_at": "2020-12-21T22:54:08.000Z", - "active": 1, - "comment_id": 749241714 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35319, - "user": { - "id": 22163939, - "login": "GrantMatejka" - }, - "type": "CR", - "created_at": "2020-12-09T23:29:03.000Z", - "active": 0, - "comment_id": 742129032 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35319, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "CR", - "created_at": "2020-12-08T01:13:04.000Z", - "active": 0, - "comment_id": 740288944 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "0e5e559c7e3bb513a7262168ed2aadccfbb029d4", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "0e5e559c7e3bb513a7262168ed2aadccfbb029d4", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "0e5e559c7e3bb513a7262168ed2aadccfbb029d4", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35404, - "state": "open", - "title": "Increase margin between paragraphs but not the last paragraph", - "body": "pull request dummy body", - "created_at": "2020-12-09T01:00:19.000Z", - "updated_at": "2020-12-10T17:23:33.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "wiki-pragraph-bottom-margin", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "0e5e559c7e3bb513a7262168ed2aadccfbb029d4", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "0e5e559c7e3bb513a7262168ed2aadccfbb029d4", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35404, + "state": "open", + "title": "Increase margin between paragraphs but not the last paragraph", + "body": "pull request dummy body", + "created_at": "2020-12-09T01:00:19.000Z", + "updated_at": "2020-12-10T17:23:33.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "wiki-pragraph-bottom-margin", + "sha": "9ca3a99018cea4bc09abc51a9bedcda5ef52d193", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "ShadyAlzayat" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35404, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-09T22:23:27.000Z", + "active": 1, + "comment_id": 742101250 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35404, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-09T22:23:27.000Z", + "active": 1, + "comment_id": 742101250 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35404, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-09T04:27:54.000Z", + "active": 0, + "comment_id": 741518613 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "9ca3a99018cea4bc09abc51a9bedcda5ef52d193", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "ShadyAlzayat" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35404, - "user": { - "id": 24502905, - "login": "bhuminson" - }, - "type": "CR", - "created_at": "2020-12-09T22:23:27.000Z", - "active": 1, - "comment_id": 742101250 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35404, - "user": { - "id": 24502905, - "login": "bhuminson" - }, - "type": "CR", - "created_at": "2020-12-09T22:23:27.000Z", - "active": 1, - "comment_id": 742101250 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35404, - "user": { - "id": 24502905, - "login": "bhuminson" - }, - "type": "CR", - "created_at": "2020-12-09T04:27:54.000Z", - "active": 0, - "comment_id": 741518613 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "9ca3a99018cea4bc09abc51a9bedcda5ef52d193", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "9ca3a99018cea4bc09abc51a9bedcda5ef52d193", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "9ca3a99018cea4bc09abc51a9bedcda5ef52d193", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35426, - "state": "open", - "title": "Akeneo 4: Add cap task to migrate it", - "body": "pull request dummy body", - "created_at": "2020-12-09T22:32:59.000Z", - "updated_at": "2020-12-23T01:35:09.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "iFixit - 12/15/2020", - "due_on": "2020-12-15T08:00:00.000Z" - }, - "head": { - "ref": "akeneo-migration--automate-it", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "9ca3a99018cea4bc09abc51a9bedcda5ef52d193", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "9ca3a99018cea4bc09abc51a9bedcda5ef52d193", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35426, + "state": "open", + "title": "Akeneo 4: Add cap task to migrate it", + "body": "pull request dummy body", + "created_at": "2020-12-09T22:32:59.000Z", + "updated_at": "2020-12-23T01:35:09.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/15/2020", + "due_on": "2020-12-15T08:00:00.000Z" + }, + "head": { + "ref": "akeneo-migration--automate-it", + "sha": "e3730b8d1339d0bee6d504400c8d0b17379229b4", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35426, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "QA", + "created_at": "2020-12-21T23:40:25.000Z", + "active": 0, + "comment_id": 749255982 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35426, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-21T23:29:10.000Z", + "active": 0, + "comment_id": 749252695 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35426, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-19T01:36:54.000Z", + "active": 0, + "comment_id": 748399125 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35426, + "user": { + "id": 589425, + "login": "sterlinghirsh" + }, + "type": "CR", + "created_at": "2020-12-11T03:52:13.000Z", + "active": 0, + "comment_id": 742950429 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35426, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-10T19:48:56.000Z", + "active": 0, + "comment_id": 742755688 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "e3730b8d1339d0bee6d504400c8d0b17379229b4", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { "sha": "e3730b8d1339d0bee6d504400c8d0b17379229b4", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "danielbeardsley" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35426, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "QA", - "created_at": "2020-12-21T23:40:25.000Z", - "active": 0, - "comment_id": 749255982 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35426, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-12-21T23:29:10.000Z", - "active": 0, - "comment_id": 749252695 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35426, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-12-19T01:36:54.000Z", - "active": 0, - "comment_id": 748399125 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35426, - "user": { - "id": 589425, - "login": "sterlinghirsh" - }, - "type": "CR", - "created_at": "2020-12-11T03:52:13.000Z", - "active": 0, - "comment_id": 742950429 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35426, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-12-10T19:48:56.000Z", - "active": 0, - "comment_id": 742755688 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "e3730b8d1339d0bee6d504400c8d0b17379229b4", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "e3730b8d1339d0bee6d504400c8d0b17379229b4", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "e3730b8d1339d0bee6d504400c8d0b17379229b4", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "external_block", - "number": 35426, - "repo": "iFixit/ifixit", - "user": "k0rvusk0r4x", - "created_at": "2020-12-14T15:46:04.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "e3730b8d1339d0bee6d504400c8d0b17379229b4", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "external_block", + "number": 35426, "repo": "iFixit/ifixit", - "number": 35471, - "state": "open", - "title": "Shipping: Use shelf weight in calculations", - "body": "pull request dummy body", - "created_at": "2020-12-14T18:35:28.000Z", - "updated_at": "2020-12-23T04:44:55.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "iFixit - 12/15/2020", - "due_on": "2020-12-15T08:00:00.000Z" - }, - "head": { - "ref": "cart--shipping--use-shelf-weight", + "user": "k0rvusk0r4x", + "created_at": "2020-12-14T15:46:04.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35471, + "state": "open", + "title": "Shipping: Use shelf weight in calculations", + "body": "pull request dummy body", + "created_at": "2020-12-14T18:35:28.000Z", + "updated_at": "2020-12-23T04:44:55.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/15/2020", + "due_on": "2020-12-15T08:00:00.000Z" + }, + "head": { + "ref": "cart--shipping--use-shelf-weight", + "sha": "1c037fa19d5278134dd4f79a5620c987ab0c840d", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "cdcline" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35471, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-21T23:08:01.000Z", + "active": 1, + "comment_id": 749246426 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35471, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-19T00:09:56.000Z", + "active": 1, + "comment_id": 748383008 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35471, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-21T23:08:01.000Z", + "active": 1, + "comment_id": 749246426 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35471, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-19T00:09:56.000Z", + "active": 1, + "comment_id": 748383008 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35471, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-17T16:02:36.000Z", + "active": 0, + "comment_id": 747533124 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35471, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "deploy_block", + "created_at": "2020-12-18T22:38:56.000Z", + "active": 1, + "comment_id": 748353358 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "1c037fa19d5278134dd4f79a5620c987ab0c840d", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { "sha": "1c037fa19d5278134dd4f79a5620c987ab0c840d", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "cdcline" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35471, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-21T23:08:01.000Z", - "active": 1, - "comment_id": 749246426 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35471, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-12-19T00:09:56.000Z", - "active": 1, - "comment_id": 748383008 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35471, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-21T23:08:01.000Z", - "active": 1, - "comment_id": 749246426 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35471, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-12-19T00:09:56.000Z", - "active": 1, - "comment_id": 748383008 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35471, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-12-17T16:02:36.000Z", - "active": 0, - "comment_id": 747533124 - } - } - ], - "dev_block": [], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35471, - "user": { - "id": 22064420, - "login": "ardelato" - }, - "type": "deploy_block", - "created_at": "2020-12-18T22:38:56.000Z", - "active": 1, - "comment_id": 748353358 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "1c037fa19d5278134dd4f79a5620c987ab0c840d", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "1c037fa19d5278134dd4f79a5620c987ab0c840d", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "1c037fa19d5278134dd4f79a5620c987ab0c840d", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "CRing1", - "number": 35471, - "repo": "iFixit/ifixit", - "user": "andyg0808", - "created_at": "2020-12-16T00:36:56.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "1c037fa19d5278134dd4f79a5620c987ab0c840d", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "CRing1", + "number": 35471, "repo": "iFixit/ifixit", - "number": 35499, - "state": "open", - "title": "Training periods: Fix out of order releases", - "body": "pull request dummy body", - "created_at": "2020-12-15T20:36:36.000Z", - "updated_at": "2020-12-17T23:06:11.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "training-periods--fix-out-of-order-releases", + "user": "andyg0808", + "created_at": "2020-12-16T00:36:56.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35499, + "state": "open", + "title": "Training periods: Fix out of order releases", + "body": "pull request dummy body", + "created_at": "2020-12-15T20:36:36.000Z", + "updated_at": "2020-12-17T23:06:11.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "training-periods--fix-out-of-order-releases", + "sha": "73635a3c5019c38a2af3cc8716d0b3fb2d2b5144", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "andrewjpiro" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-12-17T23:06:11.000Z", + "active": 1, + "comment_id": 747754875 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-17T22:17:19.000Z", + "active": 1, + "comment_id": 747735261 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-12-17T23:06:11.000Z", + "active": 1, + "comment_id": 747754875 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-17T22:17:19.000Z", + "active": 1, + "comment_id": 747735261 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-12-17T16:49:03.000Z", + "active": 0, + "comment_id": 747561960 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-12-17T00:16:19.000Z", + "active": 0, + "comment_id": 747118025 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-16T21:32:08.000Z", + "active": 0, + "comment_id": 747053986 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "deploy_block", + "created_at": "2020-12-17T00:16:19.000Z", + "active": 1, + "comment_id": 747118025 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "73635a3c5019c38a2af3cc8716d0b3fb2d2b5144", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "73635a3c5019c38a2af3cc8716d0b3fb2d2b5144", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { "sha": "73635a3c5019c38a2af3cc8716d0b3fb2d2b5144", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "andrewjpiro" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35499, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-12-17T23:06:11.000Z", - "active": 1, - "comment_id": 747754875 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35499, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "CR", - "created_at": "2020-12-17T22:17:19.000Z", - "active": 1, - "comment_id": 747735261 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35499, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-12-17T23:06:11.000Z", - "active": 1, - "comment_id": 747754875 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35499, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "CR", - "created_at": "2020-12-17T22:17:19.000Z", - "active": 1, - "comment_id": 747735261 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35499, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-12-17T16:49:03.000Z", - "active": 0, - "comment_id": 747561960 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35499, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-12-17T00:16:19.000Z", - "active": 0, - "comment_id": 747118025 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35499, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "CR", - "created_at": "2020-12-16T21:32:08.000Z", - "active": 0, - "comment_id": 747053986 - } - } - ], - "dev_block": [], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35499, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "deploy_block", - "created_at": "2020-12-17T00:16:19.000Z", - "active": 1, - "comment_id": 747118025 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "73635a3c5019c38a2af3cc8716d0b3fb2d2b5144", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "73635a3c5019c38a2af3cc8716d0b3fb2d2b5144", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "73635a3c5019c38a2af3cc8716d0b3fb2d2b5144", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "QAing", - "number": 35499, - "repo": "iFixit/ifixit", - "user": "JRodger312", - "created_at": "2020-12-15T22:47:47.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35499, "repo": "iFixit/ifixit", - "number": 35501, - "state": "open", - "title": "Add data endpoint and run Lighthouse on CI and deploy", - "body": "pull request dummy body", - "created_at": "2020-12-15T22:26:06.000Z", - "updated_at": "2020-12-23T01:32:13.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "ifixit - 12/29/2020", - "due_on": "2020-12-29T08:00:00.000Z" - }, - "head": { - "ref": "lighthouse--run-from-cimpler", + "user": "JRodger312", + "created_at": "2020-12-15T22:47:47.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35501, + "state": "open", + "title": "Add data endpoint and run Lighthouse on CI and deploy", + "body": "pull request dummy body", + "created_at": "2020-12-15T22:26:06.000Z", + "updated_at": "2020-12-23T01:32:13.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "lighthouse--run-from-cimpler", + "sha": "3cefbe88feeb6264464c7661318f2c1469237969", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "andyg0808" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-23T01:32:12.000Z", + "active": 1, + "comment_id": 749868977 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-23T01:32:12.000Z", + "active": 1, + "comment_id": 749868977 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-22T18:07:09.000Z", + "active": 0, + "comment_id": 749694651 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-22T04:41:50.000Z", + "active": 0, + "comment_id": 749334434 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-12-21T23:18:52.000Z", + "active": 0, + "comment_id": 749249570 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-21T23:07:23.000Z", + "active": 0, + "comment_id": 749246230 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-16T23:18:35.000Z", + "active": 0, + "comment_id": 747098966 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-16T17:09:07.000Z", + "active": 0, + "comment_id": 746651202 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-15T23:37:27.000Z", + "active": 0, + "comment_id": 745634633 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "dev_block", + "created_at": "2020-12-23T00:38:09.000Z", + "active": 1, + "comment_id": 749852498 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "3cefbe88feeb6264464c7661318f2c1469237969", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "andyg0808" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35501, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "CR", - "created_at": "2020-12-23T01:32:12.000Z", - "active": 1, - "comment_id": 749868977 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35501, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "CR", - "created_at": "2020-12-23T01:32:12.000Z", - "active": 1, - "comment_id": 749868977 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35501, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "CR", - "created_at": "2020-12-22T18:07:09.000Z", - "active": 0, - "comment_id": 749694651 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35501, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "CR", - "created_at": "2020-12-22T04:41:50.000Z", - "active": 0, - "comment_id": 749334434 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35501, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "CR", - "created_at": "2020-12-21T23:18:52.000Z", - "active": 0, - "comment_id": 749249570 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35501, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "CR", - "created_at": "2020-12-21T23:07:23.000Z", - "active": 0, - "comment_id": 749246230 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35501, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "CR", - "created_at": "2020-12-16T23:18:35.000Z", - "active": 0, - "comment_id": 747098966 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35501, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "CR", - "created_at": "2020-12-16T17:09:07.000Z", - "active": 0, - "comment_id": 746651202 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35501, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "CR", - "created_at": "2020-12-15T23:37:27.000Z", - "active": 0, - "comment_id": 745634633 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35501, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "dev_block", - "created_at": "2020-12-23T00:38:09.000Z", - "active": 1, - "comment_id": 749852498 - } - } - ], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "3cefbe88feeb6264464c7661318f2c1469237969", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "3cefbe88feeb6264464c7661318f2c1469237969", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "3cefbe88feeb6264464c7661318f2c1469237969", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35509, - "state": "open", - "title": "Stop recording edit limit errors to Errbit", - "body": "pull request dummy body", - "created_at": "2020-12-16T18:56:16.000Z", - "updated_at": "2020-12-23T00:20:33.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "ifixit - 12/29/2020", - "due_on": "2020-12-29T08:00:00.000Z" - }, - "head": { - "ref": "Reputation-Limits--stop-reporting-errbit", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "3cefbe88feeb6264464c7661318f2c1469237969", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "3cefbe88feeb6264464c7661318f2c1469237969", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35509, + "state": "open", + "title": "Stop recording edit limit errors to Errbit", + "body": "pull request dummy body", + "created_at": "2020-12-16T18:56:16.000Z", + "updated_at": "2020-12-23T00:20:33.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "Reputation-Limits--stop-reporting-errbit", + "sha": "da71dabf590388eea6844460386962c4a34e74af", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "bhuminson" + }, + "cr_req": 2, + "qa_req": 0, + "status": { + "qa_req": 0, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35509, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-21T19:27:40.000Z", + "active": 0, + "comment_id": 749155116 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35509, + "user": { + "id": 22163939, + "login": "GrantMatejka" + }, + "type": "CR", + "created_at": "2020-12-16T22:26:57.000Z", + "active": 0, + "comment_id": 747078014 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35509, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-16T21:58:10.000Z", + "active": 0, + "comment_id": 747065792 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35509, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-16T19:44:19.000Z", + "active": 0, + "comment_id": 746902826 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35509, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "deploy_block", + "created_at": "2020-12-22T00:08:48.000Z", + "active": 1, + "comment_id": 749264096 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35509, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "deploy_block", + "created_at": "2020-12-21T19:27:40.000Z", + "active": 1, + "comment_id": 749155116 + } + } + ], + "commit_statuses": [ + { + "data": { "sha": "da71dabf590388eea6844460386962c4a34e74af", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "bhuminson" - }, - "cr_req": 2, - "qa_req": 0, - "status": { - "qa_req": 0, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35509, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-21T19:27:40.000Z", - "active": 0, - "comment_id": 749155116 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35509, - "user": { - "id": 22163939, - "login": "GrantMatejka" - }, - "type": "CR", - "created_at": "2020-12-16T22:26:57.000Z", - "active": 0, - "comment_id": 747078014 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35509, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-16T21:58:10.000Z", - "active": 0, - "comment_id": 747065792 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35509, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-16T19:44:19.000Z", - "active": 0, - "comment_id": 746902826 - } - } - ], - "dev_block": [], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35509, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "deploy_block", - "created_at": "2020-12-22T00:08:48.000Z", - "active": 1, - "comment_id": 749264096 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35509, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "deploy_block", - "created_at": "2020-12-21T19:27:40.000Z", - "active": 1, - "comment_id": 749155116 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "da71dabf590388eea6844460386962c4a34e74af", - "target_url": "https://wwww.example.com", - "description": "Build Failure", - "state": "failure", - "context": "jest" - } - }, - { - "data": { - "sha": "da71dabf590388eea6844460386962c4a34e74af", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "da71dabf590388eea6844460386962c4a34e74af", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35517, - "state": "open", - "title": "Grafana: Update Guide Drafts and Release Versions dashboard per customer requests", - "body": "pull request dummy body", - "created_at": "2020-12-16T22:54:13.000Z", - "updated_at": "2020-12-22T22:43:23.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "grafana--update-guide-release-versions-dashboard", + "target_url": "https://wwww.example.com", + "description": "Build Failure", + "state": "failure", + "context": "jest" + } + }, + { + "data": { + "sha": "da71dabf590388eea6844460386962c4a34e74af", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "da71dabf590388eea6844460386962c4a34e74af", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35517, + "state": "open", + "title": "Grafana: Update Guide Drafts and Release Versions dashboard per customer requests", + "body": "pull request dummy body", + "created_at": "2020-12-16T22:54:13.000Z", + "updated_at": "2020-12-22T22:43:23.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "grafana--update-guide-release-versions-dashboard", + "sha": "37c8407dd115aa6fcf1b9904e929b7f68a3d446f", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "sivadnor" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "QA", + "created_at": "2020-12-22T22:42:11.000Z", + "active": 1, + "comment_id": 749815161 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-12-18T19:38:44.000Z", + "active": 1, + "comment_id": 748283348 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "CR", + "created_at": "2020-12-16T23:50:06.000Z", + "active": 1, + "comment_id": 747109662 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "QA", + "created_at": "2020-12-22T22:42:11.000Z", + "active": 1, + "comment_id": 749815161 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-12-18T19:38:44.000Z", + "active": 1, + "comment_id": 748283348 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "CR", + "created_at": "2020-12-16T23:50:06.000Z", + "active": 1, + "comment_id": 747109662 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "deploy_block", + "created_at": "2020-12-22T22:42:11.000Z", + "active": 1, + "comment_id": 749815161 + } + } + ], + "commit_statuses": [ + { + "data": { "sha": "37c8407dd115aa6fcf1b9904e929b7f68a3d446f", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "sivadnor" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35517, - "user": { - "id": 22064420, - "login": "ardelato" - }, - "type": "QA", - "created_at": "2020-12-22T22:42:11.000Z", - "active": 1, - "comment_id": 749815161 - } - } - ], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35517, - "user": { - "id": 1248182, - "login": "cdcline" - }, - "type": "CR", - "created_at": "2020-12-18T19:38:44.000Z", - "active": 1, - "comment_id": 748283348 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35517, - "user": { - "id": 4808, - "login": "scbarber" - }, - "type": "CR", - "created_at": "2020-12-16T23:50:06.000Z", - "active": 1, - "comment_id": 747109662 - } - } - ], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35517, - "user": { - "id": 22064420, - "login": "ardelato" - }, - "type": "QA", - "created_at": "2020-12-22T22:42:11.000Z", - "active": 1, - "comment_id": 749815161 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35517, - "user": { - "id": 1248182, - "login": "cdcline" - }, - "type": "CR", - "created_at": "2020-12-18T19:38:44.000Z", - "active": 1, - "comment_id": 748283348 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35517, - "user": { - "id": 4808, - "login": "scbarber" - }, - "type": "CR", - "created_at": "2020-12-16T23:50:06.000Z", - "active": 1, - "comment_id": 747109662 - } - } - ], - "dev_block": [], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35517, - "user": { - "id": 22064420, - "login": "ardelato" - }, - "type": "deploy_block", - "created_at": "2020-12-22T22:42:11.000Z", - "active": 1, - "comment_id": 749815161 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "37c8407dd115aa6fcf1b9904e929b7f68a3d446f", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "37c8407dd115aa6fcf1b9904e929b7f68a3d446f", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "37c8407dd115aa6fcf1b9904e929b7f68a3d446f", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35520, - "state": "open", - "title": "Grafana: Add a custom User Views dashboard for customer", - "body": "pull request dummy body", - "created_at": "2020-12-16T23:57:43.000Z", - "updated_at": "2020-12-23T00:44:30.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "Grafana--add-custom-user-views-dashboard", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "37c8407dd115aa6fcf1b9904e929b7f68a3d446f", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "37c8407dd115aa6fcf1b9904e929b7f68a3d446f", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35520, + "state": "open", + "title": "Grafana: Add a custom User Views dashboard for customer", + "body": "pull request dummy body", + "created_at": "2020-12-16T23:57:43.000Z", + "updated_at": "2020-12-23T00:44:30.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "Grafana--add-custom-user-views-dashboard", + "sha": "705f8729ae8c27bc873437b30593506bafb492a7", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "sivadnor" + }, + "cr_req": 1, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 1, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35520, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "QA", + "created_at": "2020-12-23T00:41:54.000Z", + "active": 1, + "comment_id": 749853461 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35520, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "QA", + "created_at": "2020-12-23T00:41:54.000Z", + "active": 1, + "comment_id": 749853461 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35520, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-12-18T19:37:58.000Z", + "active": 0, + "comment_id": 748283005 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "705f8729ae8c27bc873437b30593506bafb492a7", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "sivadnor" - }, - "cr_req": 1, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 1, - "QA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35520, - "user": { - "id": 22064420, - "login": "ardelato" - }, - "type": "QA", - "created_at": "2020-12-23T00:41:54.000Z", - "active": 1, - "comment_id": 749853461 - } - } - ], - "CR": [], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35520, - "user": { - "id": 22064420, - "login": "ardelato" - }, - "type": "QA", - "created_at": "2020-12-23T00:41:54.000Z", - "active": 1, - "comment_id": 749853461 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35520, - "user": { - "id": 1248182, - "login": "cdcline" - }, - "type": "CR", - "created_at": "2020-12-18T19:37:58.000Z", - "active": 0, - "comment_id": 748283005 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "705f8729ae8c27bc873437b30593506bafb492a7", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "705f8729ae8c27bc873437b30593506bafb492a7", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "705f8729ae8c27bc873437b30593506bafb492a7", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35524, - "state": "open", - "title": "PHP-Webdriver: Upgrade for Chrome tests only", - "body": "pull request dummy body", - "created_at": "2020-12-17T01:30:16.000Z", - "updated_at": "2020-12-22T18:19:47.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "Dozuki - Jan 5, 2020", - "due_on": "2021-01-05T08:00:00.000Z" - }, - "head": { - "ref": "php-webdriver--upgrade-for-chrome", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "705f8729ae8c27bc873437b30593506bafb492a7", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "705f8729ae8c27bc873437b30593506bafb492a7", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35524, + "state": "open", + "title": "PHP-Webdriver: Upgrade for Chrome tests only", + "body": "pull request dummy body", + "created_at": "2020-12-17T01:30:16.000Z", + "updated_at": "2020-12-22T18:19:47.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "Dozuki - Jan 5, 2020", + "due_on": "2021-01-05T08:00:00.000Z" + }, + "head": { + "ref": "php-webdriver--upgrade-for-chrome", + "sha": "209768391f2a8042341fe9973582ae4bff8275ff", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "participants": [ + "BaseInfinity", + "other", + "another" + ], + "user": { + "login": "BaseInfinity" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35524, + "user": { + "id": 29667789, + "login": "mlahargou" + }, + "type": "QA", + "created_at": "2020-12-17T18:25:37.000Z", + "active": 0, + "comment_id": 747615954 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35524, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-12-19T00:40:16.000Z", + "active": 0, + "comment_id": 748389739 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35524, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-17T16:21:15.000Z", + "active": 0, + "comment_id": 747545770 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35524, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "dev_block", + "created_at": "2020-12-19T00:40:16.000Z", + "active": 1, + "comment_id": 748389739 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35524, + "user": { + "id": 29667789, + "login": "mlahargou" + }, + "type": "deploy_block", + "created_at": "2020-12-17T18:26:12.000Z", + "active": 1, + "comment_id": 747616236 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35524, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "deploy_block", + "created_at": "2020-12-17T02:00:17.000Z", + "active": 1, + "comment_id": 747154592 + } + } + ], + "commit_statuses": [ + { + "data": { "sha": "209768391f2a8042341fe9973582ae4bff8275ff", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "participants": ["BaseInfinity", "other", "another"], - "user": { - "login": "BaseInfinity" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35524, - "user": { - "id": 29667789, - "login": "mlahargou" - }, - "type": "QA", - "created_at": "2020-12-17T18:25:37.000Z", - "active": 0, - "comment_id": 747615954 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35524, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "CR", - "created_at": "2020-12-19T00:40:16.000Z", - "active": 0, - "comment_id": 748389739 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35524, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-12-17T16:21:15.000Z", - "active": 0, - "comment_id": 747545770 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35524, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "dev_block", - "created_at": "2020-12-19T00:40:16.000Z", - "active": 1, - "comment_id": 748389739 - } - } - ], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35524, - "user": { - "id": 29667789, - "login": "mlahargou" - }, - "type": "deploy_block", - "created_at": "2020-12-17T18:26:12.000Z", - "active": 1, - "comment_id": 747616236 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35524, - "user": { - "id": 1424113, - "login": "BaseInfinity" - }, - "type": "deploy_block", - "created_at": "2020-12-17T02:00:17.000Z", - "active": 1, - "comment_id": 747154592 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "209768391f2a8042341fe9973582ae4bff8275ff", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "209768391f2a8042341fe9973582ae4bff8275ff", - "target_url": "https://wwww.example.com", - "description": "Build failure", - "state": "failure", - "context": "phpunit" - } - }, - { - "data": { - "sha": "209768391f2a8042341fe9973582ae4bff8275ff", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35529, - "state": "open", - "title": "Approval Process View: Fix memory usage with large sites", - "body": "pull request dummy body", - "created_at": "2020-12-17T17:58:27.000Z", - "updated_at": "2020-12-22T15:44:38.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "approvals--fix-memory-usage-on-view", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "209768391f2a8042341fe9973582ae4bff8275ff", + "target_url": "https://wwww.example.com", + "description": "Build failure", + "state": "failure", + "context": "phpunit" + } + }, + { + "data": { + "sha": "209768391f2a8042341fe9973582ae4bff8275ff", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35529, + "state": "open", + "title": "Approval Process View: Fix memory usage with large sites", + "body": "pull request dummy body", + "created_at": "2020-12-17T17:58:27.000Z", + "updated_at": "2020-12-22T15:44:38.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "approvals--fix-memory-usage-on-view", + "sha": "8665c60cc240c296c7dec6e01c4aef0f658f6910", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "andrewjpiro" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35529, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-12-17T18:54:16.000Z", + "active": 1, + "comment_id": 747631597 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35529, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-12-17T18:54:16.000Z", + "active": 1, + "comment_id": 747631597 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35529, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "dev_block", + "created_at": "2020-12-18T19:34:18.000Z", + "active": 1, + "comment_id": 748281488 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "8665c60cc240c296c7dec6e01c4aef0f658f6910", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "8665c60cc240c296c7dec6e01c4aef0f658f6910", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { "sha": "8665c60cc240c296c7dec6e01c4aef0f658f6910", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "andrewjpiro" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35529, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-12-17T18:54:16.000Z", - "active": 1, - "comment_id": 747631597 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35529, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2020-12-17T18:54:16.000Z", - "active": 1, - "comment_id": 747631597 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35529, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "dev_block", - "created_at": "2020-12-18T19:34:18.000Z", - "active": 1, - "comment_id": 748281488 - } - } - ], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "8665c60cc240c296c7dec6e01c4aef0f658f6910", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "8665c60cc240c296c7dec6e01c4aef0f658f6910", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "8665c60cc240c296c7dec6e01c4aef0f658f6910", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "QAing", - "number": 35529, - "repo": "iFixit/ifixit", - "user": "JRodger312", - "created_at": "2020-12-18T18:45:53.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35529, "repo": "iFixit/ifixit", - "number": 35531, - "state": "open", - "title": "DB Replica: Update hostnames", - "body": "pull request dummy body", - "created_at": "2020-12-17T20:03:41.000Z", - "updated_at": "2020-12-21T22:41:06.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "replcia-db--use-new-hostname", + "user": "JRodger312", + "created_at": "2020-12-18T18:45:53.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35531, + "state": "open", + "title": "DB Replica: Update hostnames", + "body": "pull request dummy body", + "created_at": "2020-12-17T20:03:41.000Z", + "updated_at": "2020-12-21T22:41:06.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "replcia-db--use-new-hostname", + "sha": "4398ce1c63f4796ac6af8e373e4cdfa9d50a37a8", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "participants": [ + "evannoronha", + "someone else" + ], + "user": { + "login": "evannoronha" + }, + "cr_req": 3, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 3, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35531, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-18T22:54:09.000Z", + "active": 1, + "comment_id": 748357941 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35531, + "user": { + "id": 52104630, + "login": "masonmcelvain" + }, + "type": "CR", + "created_at": "2020-12-18T22:08:56.000Z", + "active": 1, + "comment_id": 748343534 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35531, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-18T22:54:09.000Z", + "active": 1, + "comment_id": 748357941 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35531, + "user": { + "id": 52104630, + "login": "masonmcelvain" + }, + "type": "CR", + "created_at": "2020-12-18T22:08:56.000Z", + "active": 1, + "comment_id": 748343534 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35531, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "dev_block", + "created_at": "2020-12-21T21:50:14.000Z", + "active": 1, + "comment_id": 749215634 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "4398ce1c63f4796ac6af8e373e4cdfa9d50a37a8", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "participants": ["evannoronha", "someone else"], - "user": { - "login": "evannoronha" - }, - "cr_req": 3, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 3, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35531, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-18T22:54:09.000Z", - "active": 1, - "comment_id": 748357941 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35531, - "user": { - "id": 52104630, - "login": "masonmcelvain" - }, - "type": "CR", - "created_at": "2020-12-18T22:08:56.000Z", - "active": 1, - "comment_id": 748343534 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35531, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-18T22:54:09.000Z", - "active": 1, - "comment_id": 748357941 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35531, - "user": { - "id": 52104630, - "login": "masonmcelvain" - }, - "type": "CR", - "created_at": "2020-12-18T22:08:56.000Z", - "active": 1, - "comment_id": 748343534 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35531, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "dev_block", - "created_at": "2020-12-21T21:50:14.000Z", - "active": 1, - "comment_id": 749215634 - } - } - ], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "4398ce1c63f4796ac6af8e373e4cdfa9d50a37a8", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "4398ce1c63f4796ac6af8e373e4cdfa9d50a37a8", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "4398ce1c63f4796ac6af8e373e4cdfa9d50a37a8", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35536, - "state": "open", - "title": "WDTeamEditTest: Add proper configs and unskip", - "body": "pull request dummy body", - "created_at": "2020-12-17T22:44:44.000Z", - "updated_at": "2020-12-22T23:31:18.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "tests--fix-up-team-edit-test", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "4398ce1c63f4796ac6af8e373e4cdfa9d50a37a8", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "4398ce1c63f4796ac6af8e373e4cdfa9d50a37a8", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35536, + "state": "open", + "title": "WDTeamEditTest: Add proper configs and unskip", + "body": "pull request dummy body", + "created_at": "2020-12-17T22:44:44.000Z", + "updated_at": "2020-12-22T23:31:18.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "tests--fix-up-team-edit-test", + "sha": "29a559ea6a8e0932745a9f137795be29ba6bc283", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "mlahargou" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35536, + "user": { + "id": 29667789, + "login": "mlahargou" + }, + "type": "QA", + "created_at": "2020-12-22T20:05:23.000Z", + "active": 1, + "comment_id": 749750929 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35536, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-22T23:31:18.000Z", + "active": 1, + "comment_id": 749835141 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35536, + "user": { + "id": 29667789, + "login": "mlahargou" + }, + "type": "QA", + "created_at": "2020-12-22T20:05:23.000Z", + "active": 1, + "comment_id": 749750929 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35536, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-22T23:31:18.000Z", + "active": 1, + "comment_id": 749835141 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35536, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-18T22:48:42.000Z", + "active": 0, + "comment_id": 748356261 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35536, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-12-18T19:54:49.000Z", + "active": 0, + "comment_id": 748290015 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "29a559ea6a8e0932745a9f137795be29ba6bc283", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "mlahargou" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35536, - "user": { - "id": 29667789, - "login": "mlahargou" - }, - "type": "QA", - "created_at": "2020-12-22T20:05:23.000Z", - "active": 1, - "comment_id": 749750929 - } - } - ], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35536, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-22T23:31:18.000Z", - "active": 1, - "comment_id": 749835141 - } - } - ], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35536, - "user": { - "id": 29667789, - "login": "mlahargou" - }, - "type": "QA", - "created_at": "2020-12-22T20:05:23.000Z", - "active": 1, - "comment_id": 749750929 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35536, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-22T23:31:18.000Z", - "active": 1, - "comment_id": 749835141 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35536, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-18T22:48:42.000Z", - "active": 0, - "comment_id": 748356261 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35536, - "user": { - "id": 1248182, - "login": "cdcline" - }, - "type": "CR", - "created_at": "2020-12-18T19:54:49.000Z", - "active": 0, - "comment_id": 748290015 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "29a559ea6a8e0932745a9f137795be29ba6bc283", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "29a559ea6a8e0932745a9f137795be29ba6bc283", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "29a559ea6a8e0932745a9f137795be29ba6bc283", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35543, - "state": "open", - "title": "Stores: extract list provider", - "body": "pull request dummy body", - "created_at": "2020-12-18T00:48:38.000Z", - "updated_at": "2020-12-22T19:11:50.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "stores--extract-list-provider", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "29a559ea6a8e0932745a9f137795be29ba6bc283", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "29a559ea6a8e0932745a9f137795be29ba6bc283", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35543, + "state": "open", + "title": "Stores: extract list provider", + "body": "pull request dummy body", + "created_at": "2020-12-18T00:48:38.000Z", + "updated_at": "2020-12-22T19:11:50.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "stores--extract-list-provider", + "sha": "39f17dd5b7401541bbb98a302787324c3a1b3d3f", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "sctice" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35543, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-12-21T23:00:30.000Z", + "active": 1, + "comment_id": 749244160 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35543, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-12-21T23:00:30.000Z", + "active": 1, + "comment_id": 749244160 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35543, + "user": { + "id": 589425, + "login": "sterlinghirsh" + }, + "type": "CR", + "created_at": "2020-12-18T09:55:02.000Z", + "active": 0, + "comment_id": 747994235 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "39f17dd5b7401541bbb98a302787324c3a1b3d3f", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { "sha": "39f17dd5b7401541bbb98a302787324c3a1b3d3f", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "sctice" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35543, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "CR", - "created_at": "2020-12-21T23:00:30.000Z", - "active": 1, - "comment_id": 749244160 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35543, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "CR", - "created_at": "2020-12-21T23:00:30.000Z", - "active": 1, - "comment_id": 749244160 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35543, - "user": { - "id": 589425, - "login": "sterlinghirsh" - }, - "type": "CR", - "created_at": "2020-12-18T09:55:02.000Z", - "active": 0, - "comment_id": 747994235 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "39f17dd5b7401541bbb98a302787324c3a1b3d3f", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "39f17dd5b7401541bbb98a302787324c3a1b3d3f", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "39f17dd5b7401541bbb98a302787324c3a1b3d3f", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35551, - "state": "open", - "title": "Training Period: Round to beginning of the last day.", - "body": "pull request dummy body", - "created_at": "2020-12-18T18:32:37.000Z", - "updated_at": "2020-12-21T22:50:56.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "training-period--round-to-midnight", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "39f17dd5b7401541bbb98a302787324c3a1b3d3f", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35551, + "state": "open", + "title": "Training Period: Round to beginning of the last day.", + "body": "pull request dummy body", + "created_at": "2020-12-18T18:32:37.000Z", + "updated_at": "2020-12-21T22:50:56.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "training-period--round-to-midnight", + "sha": "a1eba637843a2a6880c9320c710790ed2b830b65", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "hackalot805" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35551, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-18T19:23:11.000Z", + "active": 0, + "comment_id": 748276171 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35551, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "dev_block", + "created_at": "2020-12-21T22:50:56.000Z", + "active": 1, + "comment_id": 749240790 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35551, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "deploy_block", + "created_at": "2020-12-18T19:23:11.000Z", + "active": 1, + "comment_id": 748276171 + } + } + ], + "commit_statuses": [ + { + "data": { "sha": "a1eba637843a2a6880c9320c710790ed2b830b65", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "hackalot805" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35551, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "CR", - "created_at": "2020-12-18T19:23:11.000Z", - "active": 0, - "comment_id": 748276171 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35551, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "dev_block", - "created_at": "2020-12-21T22:50:56.000Z", - "active": 1, - "comment_id": 749240790 - } - } - ], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35551, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "deploy_block", - "created_at": "2020-12-18T19:23:11.000Z", - "active": 1, - "comment_id": 748276171 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "a1eba637843a2a6880c9320c710790ed2b830b65", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "a1eba637843a2a6880c9320c710790ed2b830b65", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "a1eba637843a2a6880c9320c710790ed2b830b65", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35553, - "state": "open", - "title": "Add ability to upload documents to data capture", - "body": "pull request dummy body", - "created_at": "2020-12-18T18:48:59.000Z", - "updated_at": "2020-12-22T18:35:50.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "dozuki-2334-data-capture-documents", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "a1eba637843a2a6880c9320c710790ed2b830b65", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "a1eba637843a2a6880c9320c710790ed2b830b65", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35553, + "state": "open", + "title": "Add ability to upload documents to data capture", + "body": "pull request dummy body", + "created_at": "2020-12-18T18:48:59.000Z", + "updated_at": "2020-12-22T18:35:50.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "dozuki-2334-data-capture-documents", + "sha": "0d076e75e519f6fa0ca79bc585dc633ccbd182d1", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "rjmccluskey" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35553, + "user": { + "id": 806152, + "login": "davidrans" + }, + "type": "CR", + "created_at": "2020-12-22T16:07:22.000Z", + "active": 1, + "comment_id": 749622483 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35553, + "user": { + "id": 806152, + "login": "davidrans" + }, + "type": "CR", + "created_at": "2020-12-22T16:07:22.000Z", + "active": 1, + "comment_id": 749622483 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "0d076e75e519f6fa0ca79bc585dc633ccbd182d1", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "rjmccluskey" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35553, - "user": { - "id": 806152, - "login": "davidrans" - }, - "type": "CR", - "created_at": "2020-12-22T16:07:22.000Z", - "active": 1, - "comment_id": 749622483 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35553, - "user": { - "id": 806152, - "login": "davidrans" - }, - "type": "CR", - "created_at": "2020-12-22T16:07:22.000Z", - "active": 1, - "comment_id": 749622483 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "0d076e75e519f6fa0ca79bc585dc633ccbd182d1", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "0d076e75e519f6fa0ca79bc585dc633ccbd182d1", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "0d076e75e519f6fa0ca79bc585dc633ccbd182d1", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "0d076e75e519f6fa0ca79bc585dc633ccbd182d1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "0d076e75e519f6fa0ca79bc585dc633ccbd182d1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35554, + "state": "open", + "title": "Courses: show guide completion panel when release versions disabled", + "body": "pull request dummy body", + "created_at": "2020-12-18T19:06:39.000Z", + "updated_at": "2020-12-22T23:03:39.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "courses--fix-guide-complete-release-versions-disabled", + "sha": "1ffab577e637c5640ee34b7b125e101c07172b7e", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "bcooper94" + }, + "cr_req": 1, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 1, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35554, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-18T19:31:16.000Z", + "active": 1, + "comment_id": 748280136 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35554, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-18T19:31:16.000Z", + "active": 1, + "comment_id": 748280136 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35554, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "deploy_block", + "created_at": "2020-12-18T19:31:16.000Z", + "active": 1, + "comment_id": 748280136 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "1ffab577e637c5640ee34b7b125e101c07172b7e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "1ffab577e637c5640ee34b7b125e101c07172b7e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "1ffab577e637c5640ee34b7b125e101c07172b7e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "0 bug", + "number": 35554, "repo": "iFixit/ifixit", + "user": "bcooper94", + "created_at": "2020-12-18T19:06:39.000Z" + }, + { + "title": "r-Dozuki", "number": 35554, - "state": "open", - "title": "Courses: show guide completion panel when release versions disabled", - "body": "pull request dummy body", - "created_at": "2020-12-18T19:06:39.000Z", - "updated_at": "2020-12-22T23:03:39.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "courses--fix-guide-complete-release-versions-disabled", - "sha": "1ffab577e637c5640ee34b7b125e101c07172b7e", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "bcooper94" - }, - "cr_req": 1, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 1, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35554, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "CR", - "created_at": "2020-12-18T19:31:16.000Z", - "active": 1, - "comment_id": 748280136 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35554, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "CR", - "created_at": "2020-12-18T19:31:16.000Z", - "active": 1, - "comment_id": 748280136 - } - } - ], - "dev_block": [], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35554, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "deploy_block", - "created_at": "2020-12-18T19:31:16.000Z", - "active": 1, - "comment_id": 748280136 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "1ffab577e637c5640ee34b7b125e101c07172b7e", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "1ffab577e637c5640ee34b7b125e101c07172b7e", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "1ffab577e637c5640ee34b7b125e101c07172b7e", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "0 bug", - "number": 35554, - "repo": "iFixit/ifixit", - "user": "bcooper94", - "created_at": "2020-12-18T19:06:39.000Z" - }, - { - "title": "r-Dozuki", - "number": 35554, - "repo": "iFixit/ifixit", - "user": "bcooper94", - "created_at": "2020-12-18T19:06:39.000Z" - }, - { - "title": "r-no-notes", - "number": 35554, - "repo": "iFixit/ifixit", - "user": "bcooper94", - "created_at": "2020-12-18T19:06:39.000Z" - } - ] - }, - { "repo": "iFixit/ifixit", - "number": 35562, - "state": "open", - "title": "Shopfiy Orders API: Read and Save", - "body": "pull request dummy body", - "created_at": "2020-12-18T23:21:23.000Z", - "updated_at": "2020-12-23T01:49:44.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "ifixit - 12/29/2020", - "due_on": "2020-12-29T08:00:00.000Z" - }, - "head": { - "ref": "shopify-orders-api--read-and-save", - "sha": "b376ca6c42559055a6733ba77dd7ec8d49b76741", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "zdmitchell" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35562, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "QA", - "created_at": "2020-12-23T01:49:44.000Z", - "active": 1, - "comment_id": 749875015 - } - } - ], - "CR": [], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35562, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "QA", - "created_at": "2020-12-23T01:49:44.000Z", - "active": 1, - "comment_id": 749875015 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35562, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "QA", - "created_at": "2020-12-23T01:23:20.000Z", - "active": 0, - "comment_id": 749865571 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35562, - "user": { - "id": 51836911, - "login": "zdmitchell" - }, - "type": "QA", - "created_at": "2020-12-22T21:48:17.000Z", - "active": 0, - "comment_id": 749791703 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35562, - "user": { - "id": 72166715, - "login": "erinemay" - }, - "type": "QA", - "created_at": "2020-12-22T18:41:54.000Z", - "active": 0, - "comment_id": 749712466 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35562, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-22T18:33:57.000Z", - "active": 0, - "comment_id": 749709247 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35562, - "user": { - "id": 43477081, - "login": "jyee27" - }, - "type": "CR", - "created_at": "2020-12-21T23:00:25.000Z", - "active": 0, - "comment_id": 749244129 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35562, - "user": { - "id": 589425, - "login": "sterlinghirsh" - }, - "type": "CR", - "created_at": "2020-12-19T01:28:15.000Z", - "active": 0, - "comment_id": 748397993 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "b376ca6c42559055a6733ba77dd7ec8d49b76741", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "b376ca6c42559055a6733ba77dd7ec8d49b76741", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "b376ca6c42559055a6733ba77dd7ec8d49b76741", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { + "user": "bcooper94", + "created_at": "2020-12-18T19:06:39.000Z" + }, + { + "title": "r-no-notes", + "number": 35554, "repo": "iFixit/ifixit", - "number": 35572, - "state": "open", - "title": "Grafana: Add a custom comments dashboard", - "body": "pull request dummy body", - "created_at": "2020-12-21T17:45:10.000Z", - "updated_at": "2020-12-23T00:12:20.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "grafana--add-custom-comments-dashboard", + "user": "bcooper94", + "created_at": "2020-12-18T19:06:39.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35562, + "state": "open", + "title": "Shopfiy Orders API: Read and Save", + "body": "pull request dummy body", + "created_at": "2020-12-18T23:21:23.000Z", + "updated_at": "2020-12-23T01:49:44.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "shopify-orders-api--read-and-save", + "sha": "b376ca6c42559055a6733ba77dd7ec8d49b76741", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "zdmitchell" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "QA", + "created_at": "2020-12-23T01:49:44.000Z", + "active": 1, + "comment_id": 749875015 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "QA", + "created_at": "2020-12-23T01:49:44.000Z", + "active": 1, + "comment_id": 749875015 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "QA", + "created_at": "2020-12-23T01:23:20.000Z", + "active": 0, + "comment_id": 749865571 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "QA", + "created_at": "2020-12-22T21:48:17.000Z", + "active": 0, + "comment_id": 749791703 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 72166715, + "login": "erinemay" + }, + "type": "QA", + "created_at": "2020-12-22T18:41:54.000Z", + "active": 0, + "comment_id": 749712466 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-22T18:33:57.000Z", + "active": 0, + "comment_id": 749709247 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-21T23:00:25.000Z", + "active": 0, + "comment_id": 749244129 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 589425, + "login": "sterlinghirsh" + }, + "type": "CR", + "created_at": "2020-12-19T01:28:15.000Z", + "active": 0, + "comment_id": 748397993 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "b376ca6c42559055a6733ba77dd7ec8d49b76741", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "b376ca6c42559055a6733ba77dd7ec8d49b76741", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "b376ca6c42559055a6733ba77dd7ec8d49b76741", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35572, + "state": "open", + "title": "Grafana: Add a custom comments dashboard", + "body": "pull request dummy body", + "created_at": "2020-12-21T17:45:10.000Z", + "updated_at": "2020-12-23T00:12:20.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "grafana--add-custom-comments-dashboard", + "sha": "bb29dcda758feb3df2c34b7ac7dddc9fc761eb50", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "sivadnor" + }, + "cr_req": 0, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 0, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35572, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-23T00:12:13.000Z", + "active": 1, + "comment_id": 749845465 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35572, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-23T00:12:13.000Z", + "active": 1, + "comment_id": 749845465 + } + } + ], + "allCR": [], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35572, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "dev_block", + "created_at": "2020-12-22T22:17:00.000Z", + "active": 1, + "comment_id": 749804131 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "bb29dcda758feb3df2c34b7ac7dddc9fc761eb50", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "sivadnor" - }, - "cr_req": 0, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 0, - "QA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35572, - "user": { - "id": 58952979, - "login": "batbattur" - }, - "type": "QA", - "created_at": "2020-12-23T00:12:13.000Z", - "active": 1, - "comment_id": 749845465 - } - } - ], - "CR": [], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35572, - "user": { - "id": 58952979, - "login": "batbattur" - }, - "type": "QA", - "created_at": "2020-12-23T00:12:13.000Z", - "active": 1, - "comment_id": 749845465 - } - } - ], - "allCR": [], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35572, - "user": { - "id": 58952979, - "login": "batbattur" - }, - "type": "dev_block", - "created_at": "2020-12-22T22:17:00.000Z", - "active": 1, - "comment_id": 749804131 - } - } - ], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "bb29dcda758feb3df2c34b7ac7dddc9fc761eb50", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "bb29dcda758feb3df2c34b7ac7dddc9fc761eb50", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "bb29dcda758feb3df2c34b7ac7dddc9fc761eb50", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35577, - "state": "open", - "title": "Polish: Increase dropdown hover contrast", - "body": "pull request dummy body", - "created_at": "2020-12-21T20:39:08.000Z", - "updated_at": "2020-12-22T01:24:10.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "polish--better-notification-dropdown-contrast", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "bb29dcda758feb3df2c34b7ac7dddc9fc761eb50", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "bb29dcda758feb3df2c34b7ac7dddc9fc761eb50", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35577, + "state": "open", + "title": "Polish: Increase dropdown hover contrast", + "body": "pull request dummy body", + "created_at": "2020-12-21T20:39:08.000Z", + "updated_at": "2020-12-22T01:24:10.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "polish--better-notification-dropdown-contrast", + "sha": "da390325a89081f9ddc8d224cdc1ed4802c71c2e", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "ianrohde" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35577, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-22T00:28:18.000Z", + "active": 1, + "comment_id": 749270537 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35577, + "user": { + "id": 129007, + "login": "kwiens" + }, + "type": "QA", + "created_at": "2020-12-21T22:55:17.000Z", + "active": 0, + "comment_id": 749242143 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35577, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-22T00:28:18.000Z", + "active": 1, + "comment_id": 749270537 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35577, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-21T21:54:18.000Z", + "active": 0, + "comment_id": 749217587 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35577, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "dev_block", + "created_at": "2020-12-21T22:57:01.000Z", + "active": 1, + "comment_id": 749242937 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "da390325a89081f9ddc8d224cdc1ed4802c71c2e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { "sha": "da390325a89081f9ddc8d224cdc1ed4802c71c2e", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "ianrohde" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35577, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "CR", - "created_at": "2020-12-22T00:28:18.000Z", - "active": 1, - "comment_id": 749270537 - } - } - ], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35577, - "user": { - "id": 129007, - "login": "kwiens" - }, - "type": "QA", - "created_at": "2020-12-21T22:55:17.000Z", - "active": 0, - "comment_id": 749242143 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35577, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "CR", - "created_at": "2020-12-22T00:28:18.000Z", - "active": 1, - "comment_id": 749270537 - } - }, - { - "data": { - "repo": "iFixit/ifixit", - "number": 35577, - "user": { - "id": 24502905, - "login": "bhuminson" - }, - "type": "CR", - "created_at": "2020-12-21T21:54:18.000Z", - "active": 0, - "comment_id": 749217587 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35577, - "user": { - "id": 8008311, - "login": "rjmccluskey" - }, - "type": "dev_block", - "created_at": "2020-12-21T22:57:01.000Z", - "active": 1, - "comment_id": 749242937 - } - } - ], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "da390325a89081f9ddc8d224cdc1ed4802c71c2e", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "da390325a89081f9ddc8d224cdc1ed4802c71c2e", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "da390325a89081f9ddc8d224cdc1ed4802c71c2e", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "polish", - "number": 35577, - "repo": "iFixit/ifixit", - "user": "ianrohde", - "created_at": "2020-12-21T20:39:08.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "da390325a89081f9ddc8d224cdc1ed4802c71c2e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "polish", + "number": 35577, "repo": "iFixit/ifixit", - "number": 35583, - "state": "open", - "title": "Yelp: Regulate type of Response", - "body": "pull request dummy body", - "created_at": "2020-12-21T22:30:01.000Z", - "updated_at": "2020-12-22T00:39:25.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "ifixit - 12/29/2020", - "due_on": "2020-12-29T08:00:00.000Z" - }, - "head": { - "ref": "Yelp--dont-500-on-bad-response", + "user": "ianrohde", + "created_at": "2020-12-21T20:39:08.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35583, + "state": "open", + "title": "Yelp: Regulate type of Response", + "body": "pull request dummy body", + "created_at": "2020-12-21T22:30:01.000Z", + "updated_at": "2020-12-22T00:39:25.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "Yelp--dont-500-on-bad-response", + "sha": "92b9c886f55325033c642c5496c45d967bb3b742", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "emmalopezcode" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35583, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "QA", + "created_at": "2020-12-22T00:06:48.000Z", + "active": 1, + "comment_id": 749263306 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35583, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "QA", + "created_at": "2020-12-22T00:06:48.000Z", + "active": 1, + "comment_id": 749263306 + } + } + ], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "92b9c886f55325033c642c5496c45d967bb3b742", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "emmalopezcode" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35583, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "QA", - "created_at": "2020-12-22T00:06:48.000Z", - "active": 1, - "comment_id": 749263306 - } - } - ], - "CR": [], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35583, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "QA", - "created_at": "2020-12-22T00:06:48.000Z", - "active": 1, - "comment_id": 749263306 - } - } - ], - "allCR": [], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "92b9c886f55325033c642c5496c45d967bb3b742", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "92b9c886f55325033c642c5496c45d967bb3b742", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "92b9c886f55325033c642c5496c45d967bb3b742", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35587, - "state": "open", - "title": "[DRAFT] Newsletter: create promo page", - "body": "pull request dummy body", - "created_at": "2020-12-22T00:26:39.000Z", - "updated_at": "2020-12-23T02:52:54.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "ifixit - 12/29/2020", - "due_on": "2020-12-29T08:00:00.000Z" - }, - "head": { - "ref": "Newsletter--create-promo-page", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "92b9c886f55325033c642c5496c45d967bb3b742", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "92b9c886f55325033c642c5496c45d967bb3b742", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35587, + "state": "open", + "title": "[DRAFT] Newsletter: create promo page", + "body": "pull request dummy body", + "created_at": "2020-12-22T00:26:39.000Z", + "updated_at": "2020-12-23T02:52:54.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "Newsletter--create-promo-page", + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "Michelle5102" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35587, + "user": { + "id": 806152, + "login": "davidrans" + }, + "type": "dev_block", + "created_at": "2020-12-22T15:14:11.000Z", + "active": 1, + "comment_id": 749591094 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "Michelle5102" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35587, - "user": { - "id": 806152, - "login": "davidrans" - }, - "type": "dev_block", - "created_at": "2020-12-22T15:14:11.000Z", - "active": 1, - "comment_id": 749591094 - } - } - ], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", - "target_url": "https://wwww.example.com", - "description": "Build failure", - "state": "failure", - "context": "phpunit" - } - }, - { - "data": { - "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 45587, - "state": "open", - "title": "[DRAFT] Newsletter: create promo page", - "draft": true, - "body": "pull request dummy body", - "created_at": "2020-12-22T00:26:39.000Z", - "updated_at": "2020-12-23T02:52:54.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "ifixit - 12/29/2020", - "due_on": "2020-12-29T08:00:00.000Z" - }, - "head": { - "ref": "Newsletter--create-promo-page", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "Michelle5102" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35587, - "user": { - "id": 806152, - "login": "davidrans" - }, - "type": "dev_block", - "created_at": "2020-12-22T15:14:11.000Z", - "active": 1, - "comment_id": 749591094 - } - } - ], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", - "target_url": "https://wwww.example.com", - "description": "Build failure", - "state": "failure", - "context": "phpunit" - } - }, - { - "data": { - "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35590, - "state": "open", - "title": "Exec: discourage new files in Exec/ dir", - "body": "pull request dummy body", - "created_at": "2020-12-22T17:33:20.000Z", - "updated_at": "2020-12-22T20:09:56.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "exec-scripts--add-antipattern-test", + "target_url": "https://wwww.example.com", + "description": "Build failure", + "state": "failure", + "context": "phpunit" + } + }, + { + "data": { + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 45587, + "state": "open", + "title": "[DRAFT] Newsletter: create promo page", + "draft": true, + "body": "pull request dummy body", + "created_at": "2020-12-22T00:26:39.000Z", + "updated_at": "2020-12-23T02:52:54.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "Newsletter--create-promo-page", + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "Michelle5102" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35587, + "user": { + "id": 806152, + "login": "davidrans" + }, + "type": "dev_block", + "created_at": "2020-12-22T15:14:11.000Z", + "active": 1, + "comment_id": 749591094 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "target_url": "https://wwww.example.com", + "description": "Build failure", + "state": "failure", + "context": "phpunit" + } + }, + { + "data": { + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35590, + "state": "open", + "title": "Exec: discourage new files in Exec/ dir", + "body": "pull request dummy body", + "created_at": "2020-12-22T17:33:20.000Z", + "updated_at": "2020-12-22T20:09:56.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "exec-scripts--add-antipattern-test", + "sha": "403403dea080615fcc4cf9e0a54dd6d5c5e246c8", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35590, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "QA", + "created_at": "2020-12-22T19:18:28.000Z", + "active": 1, + "comment_id": 749727994 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35590, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "QA", + "created_at": "2020-12-22T19:18:28.000Z", + "active": 1, + "comment_id": 749727994 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35590, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-22T19:03:22.000Z", + "active": 0, + "comment_id": 749721423 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35590, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "dev_block", + "created_at": "2020-12-22T20:09:56.000Z", + "active": 1, + "comment_id": 749752880 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35590, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "deploy_block", + "created_at": "2020-12-22T19:03:22.000Z", + "active": 1, + "comment_id": 749721423 + } + } + ], + "commit_statuses": [ + { + "data": { "sha": "403403dea080615fcc4cf9e0a54dd6d5c5e246c8", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "danielbeardsley" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35590, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "QA", - "created_at": "2020-12-22T19:18:28.000Z", - "active": 1, - "comment_id": 749727994 - } - } - ], - "CR": [], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35590, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "QA", - "created_at": "2020-12-22T19:18:28.000Z", - "active": 1, - "comment_id": 749727994 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35590, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "CR", - "created_at": "2020-12-22T19:03:22.000Z", - "active": 0, - "comment_id": 749721423 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35590, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "dev_block", - "created_at": "2020-12-22T20:09:56.000Z", - "active": 1, - "comment_id": 749752880 - } - } - ], - "deploy_block": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35590, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "deploy_block", - "created_at": "2020-12-22T19:03:22.000Z", - "active": 1, - "comment_id": 749721423 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "403403dea080615fcc4cf9e0a54dd6d5c5e246c8", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "403403dea080615fcc4cf9e0a54dd6d5c5e246c8", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "403403dea080615fcc4cf9e0a54dd6d5c5e246c8", - "target_url": "https://wwww.example.com", - "description": "Build failure", - "state": "failure", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35595, - "state": "open", - "title": "Featured Product Page: Fix product schema", - "body": "pull request dummy body", - "created_at": "2020-12-22T22:12:19.000Z", - "updated_at": "2020-12-23T00:47:02.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "ifixit - 12/29/2020", - "due_on": "2020-12-29T08:00:00.000Z" - }, - "head": { - "ref": "fpp--fix-product-schema", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "403403dea080615fcc4cf9e0a54dd6d5c5e246c8", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "403403dea080615fcc4cf9e0a54dd6d5c5e246c8", + "target_url": "https://wwww.example.com", + "description": "Build failure", + "state": "failure", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35595, + "state": "open", + "title": "Featured Product Page: Fix product schema", + "body": "pull request dummy body", + "created_at": "2020-12-22T22:12:19.000Z", + "updated_at": "2020-12-23T00:47:02.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "fpp--fix-product-schema", + "sha": "ed67fa5a4e9244126463fab53bb7ad93847165e1", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "jyee27" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "ed67fa5a4e9244126463fab53bb7ad93847165e1", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "jyee27" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "ed67fa5a4e9244126463fab53bb7ad93847165e1", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "ed67fa5a4e9244126463fab53bb7ad93847165e1", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "ed67fa5a4e9244126463fab53bb7ad93847165e1", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 35597, - "state": "open", - "title": "WDAnswersSmokeTest: Deflake and robustify", - "body": "pull request dummy body", - "created_at": "2020-12-22T22:17:44.000Z", - "updated_at": "2020-12-23T01:25:37.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "tests--deflake-answers-smoke-test", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "ed67fa5a4e9244126463fab53bb7ad93847165e1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "ed67fa5a4e9244126463fab53bb7ad93847165e1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35597, + "state": "open", + "title": "WDAnswersSmokeTest: Deflake and robustify", + "body": "pull request dummy body", + "created_at": "2020-12-22T22:17:44.000Z", + "updated_at": "2020-12-23T01:25:37.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "tests--deflake-answers-smoke-test", + "sha": "c91d1c889d84c5f2f861c53c960df6e4098a9747", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "mlahargou" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35597, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-23T00:27:54.000Z", + "active": 1, + "comment_id": 749849643 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35597, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-23T01:25:36.000Z", + "active": 1, + "comment_id": 749866546 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35597, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-23T00:27:54.000Z", + "active": 1, + "comment_id": 749849643 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35597, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-23T01:25:36.000Z", + "active": 1, + "comment_id": 749866546 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "c91d1c889d84c5f2f861c53c960df6e4098a9747", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "mlahargou" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35597, - "user": { - "id": 58952979, - "login": "batbattur" - }, - "type": "QA", - "created_at": "2020-12-23T00:27:54.000Z", - "active": 1, - "comment_id": 749849643 - } - } - ], - "CR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35597, - "user": { - "id": 24502905, - "login": "bhuminson" - }, - "type": "CR", - "created_at": "2020-12-23T01:25:36.000Z", - "active": 1, - "comment_id": 749866546 - } - } - ], - "allQA": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35597, - "user": { - "id": 58952979, - "login": "batbattur" - }, - "type": "QA", - "created_at": "2020-12-23T00:27:54.000Z", - "active": 1, - "comment_id": 749849643 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/ifixit", - "number": 35597, - "user": { - "id": 24502905, - "login": "bhuminson" - }, - "type": "CR", - "created_at": "2020-12-23T01:25:36.000Z", - "active": 1, - "comment_id": 749866546 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "c91d1c889d84c5f2f861c53c960df6e4098a9747", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "jest" - } - }, - { - "data": { - "sha": "c91d1c889d84c5f2f861c53c960df6e4098a9747", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - }, - { - "data": { - "sha": "c91d1c889d84c5f2f861c53c960df6e4098a9747", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "psalm" - } - } - ], - "ready": false - }, - "labels": [] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "c91d1c889d84c5f2f861c53c960df6e4098a9747", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "c91d1c889d84c5f2f861c53c960df6e4098a9747", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/server-templates", + "number": 2284, + "state": "open", + "title": "Execute the runIdempotentOnly script", + "body": "pull request dummy body", + "created_at": "2019-07-31T20:44:03.000Z", + "updated_at": "2020-11-20T10:03:20.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "on-prem--run-idempotent-only-on-deploy", + "sha": "d35ea773eac5e112b7a768f7e46b0eb22769638c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "scbarber" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "QA", + "created_at": "2019-08-01T22:36:35.000Z", + "active": 1, + "comment_id": 517482379 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2019-07-31T20:53:03.000Z", + "active": 1, + "comment_id": 517016668 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2019-07-31T20:50:28.000Z", + "active": 1, + "comment_id": 517015857 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "QA", + "created_at": "2019-08-01T22:36:35.000Z", + "active": 1, + "comment_id": 517482379 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2019-07-31T20:53:03.000Z", + "active": 1, + "comment_id": 517016668 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2019-07-31T20:50:28.000Z", + "active": 1, + "comment_id": 517015857 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "dev_block", + "created_at": "2019-08-02T20:12:08.000Z", + "active": 1, + "comment_id": 517830078 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "deploy_block", + "created_at": "2019-08-01T23:16:10.000Z", + "active": 1, + "comment_id": 517490598 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "d35ea773eac5e112b7a768f7e46b0eb22769638c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "default" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "Cryogenic Storage", + "number": 2284, "repo": "iFixit/server-templates", + "user": "kwiens", + "created_at": "2020-01-09T22:49:32.000Z" + }, + { + "title": "external_block", "number": 2284, - "state": "open", - "title": "Execute the runIdempotentOnly script", - "body": "pull request dummy body", - "created_at": "2019-07-31T20:44:03.000Z", - "updated_at": "2020-11-20T10:03:20.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "on-prem--run-idempotent-only-on-deploy", - "sha": "d35ea773eac5e112b7a768f7e46b0eb22769638c", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "scbarber" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2284, - "user": { - "id": 4808, - "login": "scbarber" - }, - "type": "QA", - "created_at": "2019-08-01T22:36:35.000Z", - "active": 1, - "comment_id": 517482379 - } - } - ], - "CR": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2284, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "CR", - "created_at": "2019-07-31T20:53:03.000Z", - "active": 1, - "comment_id": 517016668 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2284, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "CR", - "created_at": "2019-07-31T20:50:28.000Z", - "active": 1, - "comment_id": 517015857 - } - } - ], - "allQA": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2284, - "user": { - "id": 4808, - "login": "scbarber" - }, - "type": "QA", - "created_at": "2019-08-01T22:36:35.000Z", - "active": 1, - "comment_id": 517482379 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2284, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "CR", - "created_at": "2019-07-31T20:53:03.000Z", - "active": 1, - "comment_id": 517016668 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2284, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "CR", - "created_at": "2019-07-31T20:50:28.000Z", - "active": 1, - "comment_id": 517015857 - } - } - ], - "dev_block": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2284, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "dev_block", - "created_at": "2019-08-02T20:12:08.000Z", - "active": 1, - "comment_id": 517830078 - } - } - ], - "deploy_block": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2284, - "user": { - "id": 4808, - "login": "scbarber" - }, - "type": "deploy_block", - "created_at": "2019-08-01T23:16:10.000Z", - "active": 1, - "comment_id": 517490598 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "d35ea773eac5e112b7a768f7e46b0eb22769638c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "default" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "Cryogenic Storage", - "number": 2284, - "repo": "iFixit/server-templates", - "user": "kwiens", - "created_at": "2020-01-09T22:49:32.000Z" - }, - { - "title": "external_block", - "number": 2284, - "repo": "iFixit/server-templates", - "user": "addison-grant", - "created_at": "2019-08-01T18:40:36.000Z" - }, - { - "title": "r-Dozuki", - "number": 2284, - "repo": "iFixit/server-templates", - "user": "scbarber", - "created_at": "2019-07-31T20:44:03.000Z" - } - ] - }, - { "repo": "iFixit/server-templates", - "number": 2337, - "state": "open", - "title": "Update Replicated API version", - "body": "pull request dummy body", - "created_at": "2019-10-14T18:53:17.000Z", - "updated_at": "2020-11-20T10:03:20.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "replicated-version-update", + "user": "addison-grant", + "created_at": "2019-08-01T18:40:36.000Z" + }, + { + "title": "r-Dozuki", + "number": 2284, + "repo": "iFixit/server-templates", + "user": "scbarber", + "created_at": "2019-07-31T20:44:03.000Z" + } + ] + }, + { + "repo": "iFixit/server-templates", + "number": 2337, + "state": "open", + "title": "Update Replicated API version", + "body": "pull request dummy body", + "created_at": "2019-10-14T18:53:17.000Z", + "updated_at": "2020-11-20T10:03:20.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "replicated-version-update", + "sha": "c05499c78c6e06032dc1c25e2968b8d3a018cc14", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "djmetzle" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2337, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "CR", + "created_at": "2019-10-18T20:11:44.000Z", + "active": 1, + "comment_id": 543919682 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2337, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2019-10-14T22:15:07.000Z", + "active": 1, + "comment_id": 541954022 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2337, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "CR", + "created_at": "2019-10-18T20:11:44.000Z", + "active": 1, + "comment_id": 543919682 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2337, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2019-10-14T22:15:07.000Z", + "active": 1, + "comment_id": 541954022 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2337, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "deploy_block", + "created_at": "2019-10-14T22:36:45.000Z", + "active": 1, + "comment_id": 541961199 + } + } + ], + "commit_statuses": [ + { + "data": { "sha": "c05499c78c6e06032dc1c25e2968b8d3a018cc14", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "djmetzle" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2337, - "user": { - "id": 1424113, - "login": "BaseInfinity" - }, - "type": "CR", - "created_at": "2019-10-18T20:11:44.000Z", - "active": 1, - "comment_id": 543919682 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2337, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2019-10-14T22:15:07.000Z", - "active": 1, - "comment_id": 541954022 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2337, - "user": { - "id": 1424113, - "login": "BaseInfinity" - }, - "type": "CR", - "created_at": "2019-10-18T20:11:44.000Z", - "active": 1, - "comment_id": 543919682 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2337, - "user": { - "id": 5891146, - "login": "bcooper94" - }, - "type": "CR", - "created_at": "2019-10-14T22:15:07.000Z", - "active": 1, - "comment_id": 541954022 - } - } - ], - "dev_block": [], - "deploy_block": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2337, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "deploy_block", - "created_at": "2019-10-14T22:36:45.000Z", - "active": 1, - "comment_id": 541961199 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "c05499c78c6e06032dc1c25e2968b8d3a018cc14", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "default" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "cloudprem", - "number": 2337, - "repo": "iFixit/server-templates", - "user": "simX", - "created_at": "2019-10-23T23:23:26.000Z" - }, - { - "title": "Cryogenic Storage", - "number": 2337, - "repo": "iFixit/server-templates", - "user": "kwiens", - "created_at": "2020-02-26T00:38:44.000Z" - }, - { - "title": "external_block", - "number": 2337, - "repo": "iFixit/server-templates", - "user": "addison-grant", - "created_at": "2019-10-14T23:36:56.000Z" - }, - { - "title": "onprem", - "number": 2337, - "repo": "iFixit/server-templates", - "user": "djmetzle", - "created_at": "2020-07-21T20:49:57.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "default" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "cloudprem", + "number": 2337, "repo": "iFixit/server-templates", - "number": 2835, - "state": "open", - "title": "Bumped repository version to 8 from 7 in mysql scripts", - "body": "pull request dummy body", - "created_at": "2020-11-09T20:15:35.000Z", - "updated_at": "2020-11-20T10:03:20.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "mryan/RHEL8-repos", + "user": "simX", + "created_at": "2019-10-23T23:23:26.000Z" + }, + { + "title": "Cryogenic Storage", + "number": 2337, + "repo": "iFixit/server-templates", + "user": "kwiens", + "created_at": "2020-02-26T00:38:44.000Z" + }, + { + "title": "external_block", + "number": 2337, + "repo": "iFixit/server-templates", + "user": "addison-grant", + "created_at": "2019-10-14T23:36:56.000Z" + }, + { + "title": "onprem", + "number": 2337, + "repo": "iFixit/server-templates", + "user": "djmetzle", + "created_at": "2020-07-21T20:49:57.000Z" + } + ] + }, + { + "repo": "iFixit/server-templates", + "number": 2835, + "state": "open", + "title": "Bumped repository version to 8 from 7 in mysql scripts", + "body": "pull request dummy body", + "created_at": "2020-11-09T20:15:35.000Z", + "updated_at": "2020-11-20T10:03:20.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "mryan/RHEL8-repos", + "sha": "6b5a2975a6ab853a3b809b7212ce747cf116ed10", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "renovate[bot]" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2835, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "dev_block", + "created_at": "2020-11-09T20:54:11.000Z", + "active": 1, + "comment_id": 724272110 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "6b5a2975a6ab853a3b809b7212ce747cf116ed10", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "caphene" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [], - "dev_block": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2835, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "dev_block", - "created_at": "2020-11-09T20:54:11.000Z", - "active": 1, - "comment_id": 724272110 - } - } - ], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "6b5a2975a6ab853a3b809b7212ce747cf116ed10", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "infrastructure", - "number": 2835, - "repo": "iFixit/server-templates", - "user": "djmetzle", - "created_at": "2020-11-09T20:45:39.000Z" - }, - { - "title": "onprem", - "number": 2835, - "repo": "iFixit/server-templates", - "user": "djmetzle", - "created_at": "2020-11-09T20:45:39.000Z" - }, - { - "title": "production", - "number": 2835, - "repo": "iFixit/server-templates", - "user": "djmetzle", - "created_at": "2020-11-09T20:45:39.000Z" - }, - { - "title": "storage", - "number": 2835, - "repo": "iFixit/server-templates", - "user": "djmetzle", - "created_at": "2020-11-09T20:45:40.000Z" - }, - { - "title": "upgrades", - "number": 2835, - "repo": "iFixit/server-templates", - "user": "djmetzle", - "created_at": "2020-11-09T20:45:40.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "infrastructure", + "number": 2835, "repo": "iFixit/server-templates", - "number": 2848, - "state": "open", - "title": "webpack-dev: Move to Code dir", - "body": "pull request dummy body", - "created_at": "2020-11-17T20:11:00.000Z", - "updated_at": "2020-12-22T19:26:24.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "iFixit - 12/01/2020", - "due_on": null - }, - "head": { - "ref": "webpack-dev-server--upgrade-node-version", + "user": "djmetzle", + "created_at": "2020-11-09T20:45:39.000Z" + }, + { + "title": "onprem", + "number": 2835, + "repo": "iFixit/server-templates", + "user": "djmetzle", + "created_at": "2020-11-09T20:45:39.000Z" + }, + { + "title": "production", + "number": 2835, + "repo": "iFixit/server-templates", + "user": "djmetzle", + "created_at": "2020-11-09T20:45:39.000Z" + }, + { + "title": "storage", + "number": 2835, + "repo": "iFixit/server-templates", + "user": "djmetzle", + "created_at": "2020-11-09T20:45:40.000Z" + }, + { + "title": "upgrades", + "number": 2835, + "repo": "iFixit/server-templates", + "user": "djmetzle", + "created_at": "2020-11-09T20:45:40.000Z" + } + ] + }, + { + "repo": "iFixit/server-templates", + "number": 2848, + "state": "open", + "title": "webpack-dev: Move to Code dir", + "body": "pull request dummy body", + "created_at": "2020-11-17T20:11:00.000Z", + "updated_at": "2020-12-22T19:26:24.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/01/2020", + "due_on": null + }, + "head": { + "ref": "webpack-dev-server--upgrade-node-version", + "sha": "152b1a9dc7556e9deb7b4bd4c9205a0067e41dcd", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "andyg0808" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-11-18T01:17:54.000Z", + "active": 1, + "comment_id": 729313397 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-11-17T21:25:00.000Z", + "active": 1, + "comment_id": 729223188 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-11-18T01:17:54.000Z", + "active": 1, + "comment_id": 729313397 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-11-17T21:25:00.000Z", + "active": 1, + "comment_id": 729223188 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 10889879, + "login": "addison-grant" + }, + "type": "deploy_block", + "created_at": "2020-11-21T01:09:10.000Z", + "active": 1, + "comment_id": 731481374 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 10889879, + "login": "addison-grant" + }, + "type": "deploy_block", + "created_at": "2020-11-21T00:56:45.000Z", + "active": 1, + "comment_id": 731479173 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "deploy_block", + "created_at": "2020-11-17T20:12:16.000Z", + "active": 1, + "comment_id": 729174176 + } + } + ], + "commit_statuses": [ + { + "data": { "sha": "152b1a9dc7556e9deb7b4bd4c9205a0067e41dcd", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "andyg0808" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2848, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "CR", - "created_at": "2020-11-18T01:17:54.000Z", - "active": 1, - "comment_id": 729313397 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2848, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "CR", - "created_at": "2020-11-17T21:25:00.000Z", - "active": 1, - "comment_id": 729223188 - } - } - ], - "allQA": [], - "allCR": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2848, - "user": { - "id": 26855, - "login": "danielbeardsley" - }, - "type": "CR", - "created_at": "2020-11-18T01:17:54.000Z", - "active": 1, - "comment_id": 729313397 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2848, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "CR", - "created_at": "2020-11-17T21:25:00.000Z", - "active": 1, - "comment_id": 729223188 - } - } - ], - "dev_block": [], - "deploy_block": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2848, - "user": { - "id": 10889879, - "login": "addison-grant" - }, - "type": "deploy_block", - "created_at": "2020-11-21T01:09:10.000Z", - "active": 1, - "comment_id": 731481374 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2848, - "user": { - "id": 10889879, - "login": "addison-grant" - }, - "type": "deploy_block", - "created_at": "2020-11-21T00:56:45.000Z", - "active": 1, - "comment_id": 731479173 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2848, - "user": { - "id": 1283490, - "login": "andyg0808" - }, - "type": "deploy_block", - "created_at": "2020-11-17T20:12:16.000Z", - "active": 1, - "comment_id": 729174176 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "152b1a9dc7556e9deb7b4bd4c9205a0067e41dcd", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "external_block", - "number": 2848, - "repo": "iFixit/server-templates", - "user": "addison-grant", - "created_at": "2020-11-21T01:09:20.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "external_block", + "number": 2848, "repo": "iFixit/server-templates", - "number": 2849, - "state": "open", - "title": "README update", - "body": "pull request dummy body", - "created_at": "2020-11-18T00:48:21.000Z", - "updated_at": "2020-11-24T22:59:35.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "readme-update", + "user": "addison-grant", + "created_at": "2020-11-21T01:09:20.000Z" + } + ] + }, + { + "repo": "iFixit/server-templates", + "number": 2849, + "state": "open", + "title": "README update", + "body": "pull request dummy body", + "created_at": "2020-11-18T00:48:21.000Z", + "updated_at": "2020-11-24T22:59:35.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "readme-update", + "sha": "6cb1dec7f93c85498883d0d8a816bfe2ce3deaaf", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "ardelato" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 33426643, + "login": "k0rvusk0r4x" + }, + "type": "QA", + "created_at": "2020-11-24T23:09:17.000Z", + "active": 1, + "comment_id": 733285075 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-25T16:08:45.000Z", + "active": 1, + "comment_id": 733801233 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 33426643, + "login": "k0rvusk0r4x" + }, + "type": "QA", + "created_at": "2020-11-24T23:09:17.000Z", + "active": 1, + "comment_id": 733285075 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-25T16:08:45.000Z", + "active": 1, + "comment_id": 733801233 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-23T19:21:19.000Z", + "active": 0, + "comment_id": 732372491 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-19T01:32:42.000Z", + "active": 0, + "comment_id": 730064943 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-18T16:17:51.000Z", + "active": 0, + "comment_id": 729787888 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-11-18T01:29:42.000Z", + "active": 0, + "comment_id": 729317289 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "deploy_block", + "created_at": "2020-11-19T01:23:44.000Z", + "active": 1, + "comment_id": 730062052 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "deploy_block", + "created_at": "2020-11-18T16:17:51.000Z", + "active": 1, + "comment_id": 729787888 + } + } + ], + "commit_statuses": [ + { + "data": { "sha": "6cb1dec7f93c85498883d0d8a816bfe2ce3deaaf", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "ardelato" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2849, - "user": { - "id": 33426643, - "login": "k0rvusk0r4x" - }, - "type": "QA", - "created_at": "2020-11-24T23:09:17.000Z", - "active": 1, - "comment_id": 733285075 - } - } - ], - "CR": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2849, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "CR", - "created_at": "2020-11-25T16:08:45.000Z", - "active": 1, - "comment_id": 733801233 - } - } - ], - "allQA": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2849, - "user": { - "id": 33426643, - "login": "k0rvusk0r4x" - }, - "type": "QA", - "created_at": "2020-11-24T23:09:17.000Z", - "active": 1, - "comment_id": 733285075 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2849, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "CR", - "created_at": "2020-11-25T16:08:45.000Z", - "active": 1, - "comment_id": 733801233 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2849, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "CR", - "created_at": "2020-11-23T19:21:19.000Z", - "active": 0, - "comment_id": 732372491 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2849, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "CR", - "created_at": "2020-11-19T01:32:42.000Z", - "active": 0, - "comment_id": 730064943 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2849, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "CR", - "created_at": "2020-11-18T16:17:51.000Z", - "active": 0, - "comment_id": 729787888 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2849, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "CR", - "created_at": "2020-11-18T01:29:42.000Z", - "active": 0, - "comment_id": 729317289 - } - } - ], - "dev_block": [], - "deploy_block": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2849, - "user": { - "id": 22064420, - "login": "ardelato" - }, - "type": "deploy_block", - "created_at": "2020-11-19T01:23:44.000Z", - "active": 1, - "comment_id": 730062052 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2849, - "user": { - "id": 13455801, - "login": "hackalot805" - }, - "type": "deploy_block", - "created_at": "2020-11-18T16:17:51.000Z", - "active": 1, - "comment_id": 729787888 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "6cb1dec7f93c85498883d0d8a816bfe2ce3deaaf", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/server-templates", - "number": 2864, - "state": "open", - "title": "Upgrade MySQL to 8 in onprem", - "body": "pull request dummy body", - "created_at": "2020-12-01T22:57:06.000Z", - "updated_at": "2020-12-10T22:05:19.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "mryan/mysql-8-upgrade", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/server-templates", + "number": 2864, + "state": "open", + "title": "Upgrade MySQL to 8 in onprem", + "body": "pull request dummy body", + "created_at": "2020-12-01T22:57:06.000Z", + "updated_at": "2020-12-10T22:05:19.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "mryan/mysql-8-upgrade", + "sha": "47af6afacba9a48302bc28b392a18ff7e09cd498", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "dependabot[bot]" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2864, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "deploy_block", + "created_at": "2020-12-01T23:27:55.000Z", + "active": 1, + "comment_id": 736886386 + } + } + ], + "commit_statuses": [ + { + "data": { "sha": "47af6afacba9a48302bc28b392a18ff7e09cd498", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "caphene" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [], - "dev_block": [], - "deploy_block": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2864, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "deploy_block", - "created_at": "2020-12-01T23:27:55.000Z", - "active": 1, - "comment_id": 736886386 - } - } - ], - "commit_statuses": [ - { - "data": { - "sha": "47af6afacba9a48302bc28b392a18ff7e09cd498", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - } - ], - "ready": false - }, - "labels": [ - { - "title": "external_block", - "number": 2864, - "repo": "iFixit/server-templates", - "user": "ardelato", - "created_at": "2020-12-03T00:24:43.000Z" - } - ] - }, - { + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "external_block", + "number": 2864, "repo": "iFixit/server-templates", - "number": 2882, - "state": "open", - "title": "Selenium: Only update the server, not the chromedriver", - "body": "pull request dummy body", - "created_at": "2020-12-17T02:10:41.000Z", - "updated_at": "2020-12-18T23:54:26.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": "Dozuki - 12/15/2020", - "due_on": "2020-12-15T08:00:00.000Z" - }, - "head": { - "ref": "selenium-grid--update-hub-but-not-nodes", + "user": "ardelato", + "created_at": "2020-12-03T00:24:43.000Z" + } + ] + }, + { + "repo": "iFixit/server-templates", + "number": 2882, + "state": "open", + "title": "Selenium: Only update the server, not the chromedriver", + "body": "pull request dummy body", + "created_at": "2020-12-17T02:10:41.000Z", + "updated_at": "2020-12-18T23:54:26.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "Dozuki - 12/15/2020", + "due_on": "2020-12-15T08:00:00.000Z" + }, + "head": { + "ref": "selenium-grid--update-hub-but-not-nodes", + "sha": "88398908d77aaee628865c7d5e252e6b8ae60920", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "BaseInfinity" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2882, + "user": { + "id": 29667789, + "login": "mlahargou" + }, + "type": "QA", + "created_at": "2020-12-17T17:58:04.000Z", + "active": 0, + "comment_id": 747600998 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2882, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-12-18T23:20:53.000Z", + "active": 0, + "comment_id": 748369425 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2882, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-12-17T02:20:49.000Z", + "active": 0, + "comment_id": 747161179 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2882, + "user": { + "id": 3336250, + "login": "caphene" + }, + "type": "CR", + "created_at": "2020-12-17T02:17:40.000Z", + "active": 0, + "comment_id": 747160194 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "88398908d77aaee628865c7d5e252e6b8ae60920", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "BaseInfinity" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2882, - "user": { - "id": 29667789, - "login": "mlahargou" - }, - "type": "QA", - "created_at": "2020-12-17T17:58:04.000Z", - "active": 0, - "comment_id": 747600998 - } - } - ], - "allCR": [ - { - "data": { - "repo": "iFixit/server-templates", - "number": 2882, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "CR", - "created_at": "2020-12-18T23:20:53.000Z", - "active": 0, - "comment_id": 748369425 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2882, - "user": { - "id": 6876047, - "login": "djmetzle" - }, - "type": "CR", - "created_at": "2020-12-17T02:20:49.000Z", - "active": 0, - "comment_id": 747161179 - } - }, - { - "data": { - "repo": "iFixit/server-templates", - "number": 2882, - "user": { - "id": 3336250, - "login": "caphene" - }, - "type": "CR", - "created_at": "2020-12-17T02:17:40.000Z", - "active": 0, - "comment_id": 747160194 - } - } - ], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "88398908d77aaee628865c7d5e252e6b8ae60920", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/server-templates", - "number": 2886, - "state": "closed", - "title": "Akeneo: This pull is closed and should be hidden", - "body": "pull request dummy body", - "created_at": "2020-12-23T01:36:52.000Z", - "updated_at": "2020-12-23T01:37:27.000Z", - "closed_at": null, - "merged_at": null, - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "akeneo-rds--add-chartio", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/server-templates", + "number": 2886, + "state": "closed", + "title": "Akeneo: This pull is closed and should be hidden", + "body": "pull request dummy body", + "created_at": "2020-12-23T01:36:52.000Z", + "updated_at": "2020-12-23T01:37:27.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "akeneo-rds--add-chartio", + "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "danielbeardsley" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/server-templates", - "number": 2986, - "state": "closed", - "title": "Akeneo: Finish the migration", - "body": "pull request dummy body", - "created_at": "2020-12-23T01:36:52.000Z", - "updated_at": "2020-12-23T01:37:27.000Z", - "merged_at": null, - "closed_at": "2020-12-25T01:39:27.000Z", - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "akeneo-finish-the-thing", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/server-templates", + "number": 2986, + "state": "closed", + "title": "Akeneo: Finish the migration", + "body": "pull request dummy body", + "created_at": "2020-12-23T01:36:52.000Z", + "updated_at": "2020-12-23T01:37:27.000Z", + "merged_at": null, + "closed_at": "2020-12-25T01:39:27.000Z", + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "akeneo-finish-the-thing", + "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "danielbeardsley" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - } - ], - "ready": false - }, - "labels": [] - }, - { - "repo": "iFixit/ifixit", - "number": 38986, - "state": "closed", - "title": "Routing: tripple the bypass udocles", - "body": "pull request dummy body", - "created_at": "2020-12-23T01:36:52.000Z", - "updated_at": "2020-12-23T01:37:27.000Z", - "closed_at": "2020-12-25T01:37:27.000Z", - "merged_at": "2020-12-25T01:37:27.000Z", - "difficulty": null, - "milestone": { - "title": null, - "due_on": null - }, - "head": { - "ref": "akeneo-finish-the-thing", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 38986, + "state": "closed", + "title": "Routing: tripple the bypass udocles", + "body": "pull request dummy body", + "created_at": "2020-12-23T01:36:52.000Z", + "updated_at": "2020-12-23T01:37:27.000Z", + "closed_at": "2020-12-25T01:37:27.000Z", + "merged_at": "2020-12-25T01:37:27.000Z", + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "akeneo-finish-the-thing", + "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "notme" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", - "repo": { - "owner": { - "login": "iFixit" - } - } - }, - "base": { - "ref": "master" - }, - "user": { - "login": "notme" - }, - "cr_req": 2, - "qa_req": 1, - "status": { - "qa_req": 1, - "cr_req": 2, - "QA": [], - "CR": [], - "allQA": [], - "allCR": [], - "dev_block": [], - "deploy_block": [], - "commit_statuses": [ - { - "data": { - "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", - "target_url": "https://wwww.example.com", - "description": "Build success", - "state": "success", - "context": "phpunit" - } - } - ], - "ready": false - }, - "labels": [] - } -] + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [] + } +] \ No newline at end of file diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index e45ac816..be05cddc 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { Avatar, PullTitleLink, YouSeal } from './bits'; +import { Avatar, PullTitleLink } from './bits'; /** * The one row every lens renders. Two densities of the same content: @@ -85,6 +85,7 @@ export function CardShell({ stretch = true, compact = false, own = false, + bot = false, avatarBadge, edge, depth = 0, @@ -110,9 +111,13 @@ export function CardShell({ rail?: ReactNode; stretch?: boolean; compact?: boolean; - /** the viewer authored this pull: the identity slot holds the you-coin - * (v1's blue star, minted) instead of their avatar */ + /** the viewer authored this pull: the avatar wears the you-mark (the + * corner star seated in a bite on its rim — see bits.tsx's identity + * system note) */ own?: boolean; + /** a bot/app author: the avatar renders as a rounded-square tile instead + * of a circle — shape is the whole mark */ + bot?: boolean; /** a tiny marker absolutely-positioned over the avatar (the row's starred- * author ★) — a slot rather than an Avatar prop, so this stays a one- * caller concern instead of touching every Avatar call site. */ @@ -132,13 +137,7 @@ export function CardShell({ stackStub?: boolean; }) { const titleLink = ( - + ); const connector = depth > 0 ? ( @@ -158,11 +157,13 @@ export function CardShell({ {/* raise only when the avatar is a real button — a raised inert span punches a dead zone into the whole-row click target */} - {own ? ( - - ) : ( - - )} + {avatarBadge} {/* nothing here truncates: the row flows as one tight line and @@ -191,11 +192,7 @@ export function CardShell({ > {connector} - {own ? ( - - ) : ( - - )} + {avatarBadge} diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index d99343b8..0285c7eb 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { CircleHelp } from 'lucide-react'; +import { CircleHelp, Star } from 'lucide-react'; import { Icon } from './Icon'; import { Popover } from './Popover'; import { eyebrowText } from './WordGroups'; @@ -105,6 +105,35 @@ export function Legend() { /> + + + + + + } + def="a circle avatar is a person; a square tile is a bot or app" + /> + + + + + } + def="a small corner star marks someone you starred; the larger star seated on your own avatar’s rim is you" + /> + + boolean; } /** @@ -711,13 +714,17 @@ function RowImpl({ title={d.title} body={d.body} own={d.user.login === opts.me} + bot={opts.isBotAuthor?.(d.user.login) ?? false} id={rowDomId(d)} compact={opts.compact} depth={depth} stackStub={pull.dependent && depth === 0} className={`${flashOnce(key, !!fresh) ? 'row-fresh' : ''} transition-[background-color] duration-150 ease-out motion-reduce:transition-none`} avatarBadge={ - starredAuthor && ( + // your own rows never wear it: the you-star owns that corner, + // and star-on-yourself would double the glyph + starredAuthor && + d.user.login !== opts.me && ( = 20 ? 13 : 11; + const offset = size >= 20 ? 3 : 2.5; + const center = size + offset - star / 2; + return { star, offset, center, moat: star / 2 + 2 }; +} + +/** + * The face itself: the GitHub picture over the deterministic-hue initials. * The initials sit underneath and show through the instant the image 404s * (bots, deleted accounts, an offline CDN) — so we always render something, * never a broken-image glyph. */ -/** - * The seated-star seal: the mark for the viewer's own pulls. A brand ring - * around your avatar, parted at the upper-right, with a filled brand star - * seated IN the opening on the ring's own circumference — one object, not a - * ring plus a badge. A parted ring cannot read as a focus ring (focus rings - * never break), which is what sank the plain-halo attempt. Star vocabulary - * stays one concept, people who matter to you: corner star at bottom-right - * on someone you starred; the star crowning the whole seal is you. Static - * always — identity, not state. - */ -export function YouSeal({ +function AvatarFace({ login, - size = 22, - onClick, + size, + shape = 'circle', + bitten = false, }: { login: string; - size?: number; - onClick?: (login: string) => void; + size: number; + /** 'square' = a bot/app tile (~18% corner radius); 'circle' = a person */ + shape?: 'circle' | 'square'; + /** notch the bottom-right rim out of the face so the you-star can seat + * IN the silhouette rather than merely on top of it */ + bitten?: boolean; }) { - const compact = size <= 16; - return ( - - - {compact ? ( - - - - - ) : ( - - - - - )} - - ); -} - -function AvatarFace({ login, size }: { login: string; size: number }) { const [broken, setBroken] = useState(false); // OKLCH holds perceived lightness constant across the hue wheel — the old // hsl(h 45% 45%) made yellow-green logins illegible under white text - const style = { + const style: CSSProperties = { background: `oklch(0.48 0.09 ${loginHue(login)})`, width: size, height: size, fontSize: Math.round(size * 0.42), + borderRadius: shape === 'square' ? Math.max(3, Math.round(size * 0.18)) : 9999, }; + if (bitten) { + const g = youStarGeometry(size); + // transparent moat, not a painted stroke: the bite stays correct over + // any row background (hover, fresh-flash), which the old seal's + // surface-colored outline never quite did + const mask = `radial-gradient(circle at ${g.center}px ${g.center}px, transparent ${g.moat - 0.25}px, #000 ${g.moat + 0.25}px)`; + style.WebkitMaskImage = mask; + style.maskImage = mask; + } return ( {login.slice(0, 2)} @@ -290,15 +251,39 @@ export function Avatar({ login, size = 22, onClick, + shape = 'circle', + you = false, }: { login: string; size?: number; onClick?: (login: string) => void; + /** 'square' for bot/app authors — see the identity-system note above */ + shape?: 'circle' | 'square'; + /** the viewer's own avatar: wears the seated corner star + bitten rim */ + you?: boolean; }) { + const g = you ? youStarGeometry(size) : null; + // the star rides INSIDE whatever scales on hover, so the bite in the face + // and the star seated in it can never fall out of registration + const face = ( + + + {g && ( + + + + )} + + ); if (!onClick) { return ( - + {face} ); } @@ -321,11 +306,11 @@ export function Avatar({ type="button" // .hit: the circle is 16-22px, under the 24px target floor className="hit pressable cursor-pointer border-0 p-0 transition-[scale] duration-150 ease-out hover:scale-115 motion-reduce:transition-none" - aria-label={`${login}: view their PRs`} + aria-label={you ? `${login} (you): view your PRs` : `${login}: view their PRs`} // click filters to this person; the hover card is the extra onClick={() => onClick(login)} > - + {face} )} > From 99fbe1dcd76e74e3c91425a86b9213d177d03a2a Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 13:44:14 -0700 Subject: [PATCH 301/381] Name the matched code region on the row again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The icon-unification pass demoted the region chip to a bare 10px diamond with the names one hover away — and "which of my regions matched?" is exactly the question the mark exists to answer at a glance. The diamond now leads a quiet ink-3 label naming the region ("Shopify", "+N" past the first); still plain meta text, not a chip, so brand keeps meaning "your move". The full sentence keeps its hover door. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Row.tsx | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 60ff8245..75a13449 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -757,17 +757,17 @@ function RowImpl({ {regions.length > 0 && !opts.hideRegionMark && ( - // a bare hover-door mark, not a chip: region-match is soft - // personalization, not urgency, and inside the "In your code - // regions" lanes it would only repeat what the lane header - // already says — a filled brand chip diluted "blue = your - // move" (color audit), and naming every region inline - // out-weighed a 10px glyph (2026-07 icon pass) + // the brand diamond leads and the matched region is NAMED in + // quiet ink beside it — which of your regions hit must read + // without a hover (a bare glyph hid it; owner call). No + // border, no fill: plain meta text, not a chip, so "blue = + // your move" stays undiluted. The full sentence keeps its + // hover door. ( @@ -775,9 +775,16 @@ function RowImpl({ {...t} type="button" aria-label={`in your code ${regions.length > 1 ? 'regions' : 'region'}: ${regions.join(', ')}`} - className="pressable -my-2 flex-none rounded px-0.5 py-2 text-brand hover:bg-secondary/60" + className="pressable -my-2 flex min-w-0 flex-none items-center gap-1 rounded px-0.5 py-2 hover:bg-secondary/60" > - + + + + + {regions.length > 1 + ? `${regions[0]} +${regions.length - 1}` + : regions[0]} + )} > From bc47dae84ad1807a54a8a86536d994e717bfbe52 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 13:45:41 -0700 Subject: [PATCH 302/381] Lens tabs become a dropdown at phone width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six-tab strip horizontally scrolls below sm, which nobody discovers at iPhone width — lenses past the fold may as well not exist. Below sm the strip yields to a single trigger (current lens + chevron) opening the house Popover with all six lenses and their counts; sm and up keep the tab strip untouched. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 43 ++++++++++--- frontend-v2/src/components/LensMenu.tsx | 85 +++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 frontend-v2/src/components/LensMenu.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 7425d065..0393d20c 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -27,6 +27,7 @@ import { CRYO_KEY, isBotLogin, personHidden, repoHidden } from './model/visibili import { reviewRequestedFrom } from './model/reviewers'; import { foldDomId, openFold } from './components/Lane'; import { Legend } from './components/Legend'; +import { LensMenu } from './components/LensMenu'; import { Logo } from './components/Logo'; import { NotificationPanel } from './components/NotificationPanel'; import { RepoFilter } from './components/filters/RepoFilter'; @@ -45,10 +46,21 @@ import { Ci } from './views/Ci'; import { Stats } from './views/Stats'; import { Settings } from './components/Settings'; -type Lens = 'review' | 'mine' | 'team' | 'classic' | 'ci' | 'stats'; +export type Lens = 'review' | 'mine' | 'team' | 'classic' | 'ci' | 'stats'; const LENSES: Lens[] = ['review', 'mine', 'team', 'classic', 'ci', 'stats']; +/** the tab strip's own copy for each lens — lifted so the phone dropdown + * (LensMenu) can reuse it instead of restating the six strings */ +const LENS_LABELS: Record = { + review: 'Review', + mine: 'My work', + team: 'Team', + classic: 'Classic', + ci: 'CI', + stats: 'Stats', +}; + /** every actionState bucket, for validating the `state=` hash param against */ const ACTION_STATE_KEYS: ActionStateKey[] = [ 'restamp', @@ -879,15 +891,28 @@ export function App() {
{/* min-w-0 + overflow-x-auto (no-scrollbar in styles.css) turns a too-narrow tab strip into a swipe rather than a - wrap ("My work 5" splitting) or a page-widening overflow. */} -
diff --git a/frontend-v2/src/components/LensMenu.tsx b/frontend-v2/src/components/LensMenu.tsx new file mode 100644 index 00000000..e057b50d --- /dev/null +++ b/frontend-v2/src/components/LensMenu.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { ChevronDown } from 'lucide-react'; +import type { Lens } from '../app'; +import { Icon } from './Icon'; +import { Popover } from './Popover'; + +export interface LensOption { + id: Lens; + label: string; + count?: number; +} + +/** + * The phone-width twin of app.tsx's lens tab strip: below `sm` the six tabs + * no longer fit and silently horizontal-scroll (no-scrollbar hides the + * scrollbar too, so the swipe is undiscoverable on a phone) — this renders + * the same six choices as a single Popover dropdown instead, the same + * trigger-plus-portal-panel composition StateFilter uses for its own + * single-choice list. + * + * `pick` is keyed onto the Popover so every selection remounts it, forcing + * its internal open state back to closed. Popover's `children` have no + * exposed close callback (its other consumers are multi-select filters that + * stay open across picks), and this reuses the component exactly as-is + * rather than growing its API for one caller. + */ +export function LensMenu({ + lens, + setLens, + options, + className = '', +}: { + lens: Lens; + setLens: (next: Lens) => void; + options: LensOption[]; + className?: string; +}) { + const [pick, setPick] = useState(0); + const current = options.find(o => o.id === lens); + + return ( +
+ ( + + )} + > + {options.map(({ id, label, count }) => ( + + ))} + +
+ ); +} From 6d97651457322955e5404aaa7144f79c7b4a2f98 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 13:46:31 -0700 Subject: [PATCH 303/381] Human names: resolve logins through a cached server lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRs only carry GitHub logins, but people think in names — "who is cdcline?" should never require leaving the board. A new authenticated /user-names endpoint batches GET /users/:login through the server's existing Octokit (24h in-memory cache, negative caching, 100 logins per request), and a debounced client store (model/names.ts) resolves every author on the board once per day per browser, with a dummy-mode fixture so the demo board stays fully offline. Names now show in the avatar hover card (name first, @login beneath), the People filter, the team picker, and the Team directory chips — and search matches them everywhere logins matched before: "casey" finds cdcline's pulls in the filter box, author:metz finds djmetzle, and both pickers answer to either handle or name. Co-Authored-By: Claude Fable 5 --- app.js | 2 + controllers/user-names.js | 142 ++++++++++++++ frontend-v2/src/app.tsx | 19 +- frontend-v2/src/components/TeamPicker.tsx | 17 +- frontend-v2/src/components/bits.tsx | 14 +- .../src/components/filters/PeopleFilter.tsx | 15 +- frontend-v2/src/model/names.test.ts | 152 +++++++++++++++ frontend-v2/src/model/names.ts | 180 ++++++++++++++++++ frontend-v2/src/model/query.ts | 25 ++- frontend-v2/src/views/Team.tsx | 5 +- frontend-v2/vite.config.ts | 1 + lib/authentication.js | 1 + test/user-names.test.js | 99 ++++++++++ 13 files changed, 653 insertions(+), 19 deletions(-) create mode 100644 controllers/user-names.js create mode 100644 frontend-v2/src/model/names.test.ts create mode 100644 frontend-v2/src/model/names.ts create mode 100644 test/user-names.test.js diff --git a/app.js b/app.js index 9d5933cf..0b4f787a 100644 --- a/app.js +++ b/app.js @@ -12,6 +12,7 @@ import pullQueue from './lib/pull-queue.js'; import mainController from './controllers/main.js'; import hooksController from './controllers/githubHooks.js'; import statsController from './controllers/stats.js'; +import userNamesController from './controllers/user-names.js'; import Debug from './lib/debug.js'; import { createServer } from 'http'; import { Server } from 'socket.io'; @@ -63,6 +64,7 @@ app.use('/v1', express.static(__dirname + '/frontend/dist')); app.use('/', express.static(__dirname + '/frontend-v2/dist')); app.get('/token', mainController.getToken); app.get('/stats-history', statsController.getHistory); +app.get('/user-names', userNamesController.getNames); app.post('/hooks/main', hooksController.main); // Warm the bot-login cache (used to tell a pulldasher claim apart from a diff --git a/controllers/user-names.js b/controllers/user-names.js new file mode 100644 index 00000000..eaaee7b3 --- /dev/null +++ b/controllers/user-names.js @@ -0,0 +1,142 @@ +import git from '../lib/git-manager.js'; +import debug from '../lib/debug.js'; + +const userNamesDebug = debug('pulldasher:user-names'); + +// This moves slowly (a login's display name essentially never changes) and +// every open pull re-requests the same handful of authors/reviewers, so cache +// per-login for a day, shared across every request (mirrors controllers/stats.js's +// CACHE_TTL_MS memo). A 404/error caches to `null` too (negative cache) -- +// otherwise a bot or deleted account would hit GitHub on every single request. +export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +// GitHub logins are already short; capping the batch keeps one request from +// fanning out into an unbounded number of concurrent Octokit calls. +export const MAX_LOGINS = 100; + +const CONCURRENCY = 5; + +// GitHub logins are alphanumeric + hyphens; app-created logins add a +// `[bot]` suffix (e.g. `dependabot[bot]`). Anything else isn't a login this +// endpoint will ever legitimately be asked about. +export const LOGIN_RE = /^[A-Za-z0-9-[\]]+$/; + +export function isValidLogin(login) { + return typeof login === 'string' && LOGIN_RE.test(login); +} + +/** + * Splits "alice, bob,,alice" into ["alice", "bob"] -- trimmed, blanks + * dropped, de-duplicated (first occurrence wins), order preserved. Does not + * filter by isValidLogin -- callers decide what to do with the ones that + * fail sanitization. + */ +export function parseLoginsParam(raw) { + if (!raw) return []; + const seen = new Set(); + for (const part of raw.split(',')) { + const login = part.trim(); + if (login) seen.add(login); + } + return [...seen]; +} + +/** + * Whether a cache entry ({ name, at }) is still within CACHE_TTL_MS of `now`. + * A missing entry is never fresh. + */ +export function isFresh(entry, now = Date.now()) { + return Boolean(entry) && now - entry.at < CACHE_TTL_MS; +} + +/** + * Resolves `logins` (already sanitized, deduplicated) to display names. + * Reuses any entry in `cache` still fresh under CACHE_TTL_MS; fetches the + * rest through `fetcher` (real callers pass one backed by Octokit; tests pass + * a stub) with at most `concurrency` requests in flight at once. GitHub's + * rate limit is already handled by the Octokit throttling plugin (see + * lib/git-manager.js) -- this concurrency cap only exists so a 100-login + * request doesn't open 100 sockets at once, not as a second rate limiter. + * + * `cache` is a login -> { name, at } Map, mutated in place -- including + * negative entries, so a login that 404s or errors is remembered as `null` + * for CACHE_TTL_MS rather than retried on every request. Production callers + * share one Map across requests (module-level, below); tests pass a fresh one. + */ +export async function resolveNames( + logins, + { cache, fetcher, concurrency = CONCURRENCY, now = Date.now() } +) { + const toFetch = logins.filter(login => !isFresh(cache.get(login), now)); + const queue = [...toFetch]; + + async function worker() { + let login; + while ((login = queue.shift()) !== undefined) { + const name = await fetcher(login); + cache.set(login, { name, at: Date.now() }); + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, toFetch.length) }, worker)); + + const names = {}; + for (const login of logins) { + names[login] = cache.get(login)?.name ?? null; + } + return names; +} + +/** Real fetcher: one login's `name` field from GitHub (may legitimately be + * null -- lots of users don't set one). Never rejects -- a 404 (deleted/renamed + * account) or any other error just resolves to null, same as "no name set". */ +function githubFetcher(login) { + return git.github.users + .getByUsername({ username: login }) + .then(res => res.data.name ?? null) + .catch(err => { + userNamesDebug( + 'Could not fetch name for %s: %s', + login, + (err && err.status) || (err && err.message) || err + ); + return null; + }); +} + +// Shared across every request for the life of the process. +const cache = new Map(); + +export default { + /** + * GET /user-names?logins=a,b,c -- resolve GitHub logins to human display + * names, for the frontend's people-facing UI (PRs only carry logins on + * the wire). Auth-gated exactly like /token (see lib/authentication.js + * setupRoutes). Every requested login gets an entry in the response, even + * ones that fail sanitization or that GitHub has no name for -- those are + * `null`, never dropped from the response shape. + */ + getNames: function (req, res) { + const raw = typeof req.query.logins === 'string' ? req.query.logins : ''; + const requested = parseLoginsParam(raw); + + if (requested.length > MAX_LOGINS) { + return res.status(400).json({ error: `logins capped at ${MAX_LOGINS} per request` }); + } + + const validLogins = requested.filter(isValidLogin); + + resolveNames(validLogins, { cache, fetcher: githubFetcher }) + .then(names => { + const body = {}; + for (const login of requested) { + body[login] = names[login] ?? null; + } + res.json({ names: body }); + }) + .catch(err => { + console.error('user-names query failed:', err); + res.status(500).json({ error: 'user-names query failed' }); + }); + }, +}; diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 0393d20c..7abb1d75 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -23,6 +23,7 @@ import { getSettings, useSettings } from './settings'; import { useNotifications } from './notifications'; import { ToastStack, useToasts } from './toasts'; import { matchesQuery } from './model/query'; +import { requestNames, useNames } from './model/names'; import { CRYO_KEY, isBotLogin, personHidden, repoHidden } from './model/visibility'; import { reviewRequestedFrom } from './model/reviewers'; import { foldDomId, openFold } from './components/Lane'; @@ -212,6 +213,20 @@ export function App() { } = usePulldasher(); // desktop notifications watch the whole board, not the current filter useNotifications(pulls, me, initialized); + // resolve every author on the board to a human display name (batched, + // cached — model/names.ts): the person hover-cards, the people pickers, + // and name-aware search all read from this one map + const names = useNames(); + useEffect(() => { + const s = getSettings(); + requestNames([ + ...pulls.map(p => p.data.user.login), + ...closed.map(p => p.user.login), + ...s.myTeam, + ...s.starredPeople, + ...s.hiddenPeople, + ]); + }, [pulls, closed]); const [scope, setScope] = useScope(); const [lens, setLens] = useState(() => readHash().lens); const [person, setPerson] = useState(() => urlState.person); @@ -501,9 +516,9 @@ export function App() { // no matter whose work you follow (they land in the bots fold, not lanes) if (scope.authors.length) out = out.filter(p => isBot(p) || scope.authors.includes(p.data.user.login)); - if (query) out = out.filter(p => matchesQuery(p, query, me)); + if (query) out = out.filter(p => matchesQuery(p, query, me, names)); return out; - }, [pulls, boardHidden, scope, isBot, query]); + }, [pulls, boardHidden, scope, isBot, query, names, me]); // preWeightScoped narrowed by the Weight filter, but not yet State — // StateFilter's own live counts read this pool for the same diff --git a/frontend-v2/src/components/TeamPicker.tsx b/frontend-v2/src/components/TeamPicker.tsx index bdaf9604..beb2d496 100644 --- a/frontend-v2/src/components/TeamPicker.tsx +++ b/frontend-v2/src/components/TeamPicker.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react'; +import { displayName, useNames } from '../model/names'; import { isBotLogin } from '../model/visibility'; import { toggleTeammate, useSettings } from '../settings'; import { usePulldasher } from '../store'; @@ -13,12 +14,15 @@ const EMPTY_BOTS: ReadonlySet = new Set(); * wherever it appears. */ function CandidateRow({ login, + name, count, checked, self, onToggle, }: { login: string; + /** human display name, when known (model/names.ts) */ + name?: string | null; count?: number; checked: boolean; self?: boolean; @@ -35,7 +39,7 @@ function CandidateRow({ /> - {login} + {name ?? login} {self && (you)} {count != null && ( @@ -85,9 +89,16 @@ export function TeamPicker({ extraBots = EMPTY_BOTS }: { extraBots?: ReadonlySet const members = new Set(myTeam); const trimmed = query.trim(); const needle = trimmed.toLowerCase(); + // search by handle OR human name — "metz" should find djmetzle + const namesMap = useNames(); + const nameOf = (login: string) => displayName(namesMap, login); const available = candidates.filter(c => !members.has(c.login)); const filtered = needle - ? available.filter(c => c.login.toLowerCase().includes(needle)) + ? available.filter( + c => + c.login.toLowerCase().includes(needle) || + (nameOf(c.login) ?? '').toLowerCase().includes(needle) + ) : available.slice(0, SUGGESTION_CAP); const exactMatch = @@ -99,6 +110,7 @@ export function TeamPicker({ extraBots = EMPTY_BOTS }: { extraBots?: ReadonlySet toggleTeammate(login, false)} @@ -116,6 +128,7 @@ export function TeamPicker({ extraBots = EMPTY_BOTS }: { extraBots?: ReadonlySet requestNames([login]), [login]); + const name = displayName(names, login); return (
@@ -232,7 +236,7 @@ function PersonCard({ login }: { login: string }) { className="block truncate font-semibold text-ink hover:underline" title={`@${login} on GitHub`} > - {login} + {name ?? login} - GitHub profile ↗ + {name ? `@${login} · ` : ''}GitHub profile ↗
diff --git a/frontend-v2/src/components/filters/PeopleFilter.tsx b/frontend-v2/src/components/filters/PeopleFilter.tsx index 8749c763..cda8b339 100644 --- a/frontend-v2/src/components/filters/PeopleFilter.tsx +++ b/frontend-v2/src/components/filters/PeopleFilter.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import type { DerivedPull } from '../../model/status'; +import { displayName, useNames } from '../../model/names'; import { toggleHiddenPerson, toggleStarredPerson, useSettings } from '../../settings'; import { usePulldasher } from '../../store'; import type { Team } from '../../types'; @@ -30,6 +31,12 @@ export function PeopleFilter({ }) { const { me } = usePulldasher(); const settings = useSettings(); + // login -> human name (app.tsx prefetches everyone on the board), so the + // list can read and search by the person, not just the handle + const namesMap = useNames(); + const nameOf = (login: string) => displayName(namesMap, login); + const matchesPerson = (login: string, q: string) => + login.toLowerCase().includes(q) || (nameOf(login) ?? '').toLowerCase().includes(q); const starredSet = new Set(settings.starredPeople); const hiddenSet = new Set(settings.hiddenPeople); const [peopleQuery, setPeopleQuery] = useState(''); @@ -66,10 +73,10 @@ export function PeopleFilter({ : `${scope.authors.length} people`; const filteredShown = shownAuthors.filter(([login]) => - login.toLowerCase().includes(peopleQuery.toLowerCase()) + matchesPerson(login, peopleQuery.toLowerCase()) ); const filteredHidden = hiddenAuthors.filter(([login]) => - login.toLowerCase().includes(peopleQuery.toLowerCase()) + matchesPerson(login, peopleQuery.toLowerCase()) ); return ( @@ -129,7 +136,7 @@ export function PeopleFilter({ /> - {login} + {nameOf(login) ?? login} {count || ''} @@ -178,7 +185,7 @@ export function PeopleFilter({ title={login} className="min-w-0 flex-1 truncate text-[13px] text-ink-3" > - {login} + {nameOf(login) ?? login}
{count || ''} diff --git a/frontend-v2/src/model/names.test.ts b/frontend-v2/src/model/names.test.ts new file mode 100644 index 00000000..8566f160 --- /dev/null +++ b/frontend-v2/src/model/names.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment jsdom +// +// The rest of model/ is pure functions over plain data, so it runs fine in +// the default (node) vitest environment -- see vite.config.ts / package.json +// for why nothing else opts into jsdom. This module is a stateful store that +// touches sessionStorage, so it needs a real Storage implementation; the +// per-file `@vitest-environment` pragma above scopes that to just this file. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Comfortably past the module's ~250ms debounce window. +const DEBOUNCE_WINDOW = 300; + +function jsonResponse(body: unknown): Response { + return { ok: true, json: async () => body } as Response; +} + +async function importLive(fetchImpl: (url: string) => Promise) { + vi.doMock('../backend/dummy', () => ({ isDummy: () => false })); + const fetchMock = vi.fn(fetchImpl); + vi.stubGlobal('fetch', fetchMock); + const names = await import('./names'); + return { ...names, fetchMock }; +} + +describe('model/names', () => { + beforeEach(() => { + vi.resetModules(); + sessionStorage.clear(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.doUnmock('../backend/dummy'); + }); + + it('dedupes a login already known or already in flight', async () => { + const { requestNames, getNames, fetchMock } = await importLive(async () => + jsonResponse({ names: { alice: 'Alice A' } }) + ); + + requestNames(['alice']); + requestNames(['alice']); // still pending in this window: no second entry + await vi.advanceTimersByTimeAsync(DEBOUNCE_WINDOW); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getNames().alice).toBe('Alice A'); + + requestNames(['alice']); // already resolved: must not refetch + await vi.advanceTimersByTimeAsync(DEBOUNCE_WINDOW); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('batches logins requested within the debounce window into one fetch', async () => { + const { requestNames, getNames, fetchMock } = await importLive(async () => + jsonResponse({ names: { alice: 'Alice A', bob: null } }) + ); + + requestNames(['alice']); + requestNames(['bob']); + await vi.advanceTimersByTimeAsync(DEBOUNCE_WINDOW); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = fetchMock.mock.calls[0]![0] as string; + expect(url).toContain('alice'); + expect(url).toContain('bob'); + expect(getNames()).toEqual({ alice: 'Alice A', bob: null }); + }); + + it('opens a new debounce window once the previous batch has flushed', async () => { + const { requestNames, fetchMock } = await importLive(async () => jsonResponse({ names: {} })); + + requestNames(['alice']); + await vi.advanceTimersByTimeAsync(DEBOUNCE_WINDOW); + requestNames(['bob']); + await vi.advanceTimersByTimeAsync(DEBOUNCE_WINDOW); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('leaves a login unresolved (retryable) after a failed fetch, rather than caching null', async () => { + const { requestNames, getNames, fetchMock } = await importLive(async () => { + throw new Error('network down'); + }); + + requestNames(['alice']); + await vi.advanceTimersByTimeAsync(DEBOUNCE_WINDOW); + expect(getNames()).toEqual({}); + + // a later request retries rather than staying permanently unknown + fetchMock.mockResolvedValue(jsonResponse({ names: { alice: 'Alice A' } })); + requestNames(['alice']); + await vi.advanceTimersByTimeAsync(DEBOUNCE_WINDOW); + expect(getNames().alice).toBe('Alice A'); + }); + + it('round-trips the resolved map through sessionStorage across a reload', async () => { + const first = await importLive(async () => jsonResponse({ names: { carol: 'Carol C' } })); + first.requestNames(['carol']); + await vi.advanceTimersByTimeAsync(DEBOUNCE_WINDOW); + expect(first.getNames().carol).toBe('Carol C'); + + // Simulate a reload: a fresh module instance, same sessionStorage. + vi.resetModules(); + vi.doMock('../backend/dummy', () => ({ isDummy: () => false })); + const secondFetch = vi.fn(); + vi.stubGlobal('fetch', secondFetch); + const second = await import('./names'); + + expect(second.getNames().carol).toBe('Carol C'); + second.requestNames(['carol']); + await vi.advanceTimersByTimeAsync(DEBOUNCE_WINDOW); + expect(secondFetch).not.toHaveBeenCalled(); + }); + + it('treats a sessionStorage entry older than 24h as expired', async () => { + sessionStorage.setItem( + 'pd2.names', + JSON.stringify({ at: Date.now() - 25 * 3600_000, names: { carol: 'Carol C' } }) + ); + vi.doMock('../backend/dummy', () => ({ isDummy: () => false })); + const { getNames } = await import('./names'); + expect(getNames()).toEqual({}); + }); + + it('dummy mode resolves synchronously from a fixture, never touching the network', async () => { + vi.doMock('../backend/dummy', () => ({ isDummy: () => true })); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const { requestNames, getNames, displayName } = await import('./names'); + + requestNames(['danielbeardsley', 'some-unknown-login']); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(getNames().danielbeardsley).toBe('Daniel Beardsley'); + expect(getNames()['some-unknown-login']).toBeNull(); + expect(displayName(getNames(), 'danielbeardsley')).toBe('Daniel Beardsley'); + expect(displayName(getNames(), 'some-unknown-login')).toBeNull(); + }); +}); + +describe('displayName', () => { + it('returns the name when known and non-empty, else null', async () => { + vi.doMock('../backend/dummy', () => ({ isDummy: () => false })); + const { displayName } = await import('./names'); + expect(displayName({}, 'nobody')).toBeNull(); + expect(displayName({ ghost: null }, 'ghost')).toBeNull(); + expect(displayName({ blank: '' }, 'blank')).toBeNull(); + expect(displayName({ alice: 'Alice A' }, 'alice')).toBe('Alice A'); + }); +}); diff --git a/frontend-v2/src/model/names.ts b/frontend-v2/src/model/names.ts new file mode 100644 index 00000000..d3904cca --- /dev/null +++ b/frontend-v2/src/model/names.ts @@ -0,0 +1,180 @@ +import { useSyncExternalStore } from 'react'; +import { isDummy } from '../backend/dummy'; + +/** + * Login -> human display name, resolved server-side via GET /user-names (the + * server's own Octokit-backed cache -- see controllers/user-names.js). PRs + * only ever carry GitHub logins on the wire; this is the one place that + * turns them into names for display, following the same + * subscribe/useSyncExternalStore store shape as store.ts and settings.ts. + * + * `raw[login]` states: + * - absent: never requested, or a request for it is still pending/in flight + * - null: resolved -- GitHub has no name on file (or the login 404s) + * - string: resolved -- this is the name + */ + +const STORAGE_KEY = 'pd2.names'; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const DEBOUNCE_MS = 250; +// Mirrors the server's own per-request cap (controllers/user-names.js +// MAX_LOGINS) -- chunk instead of risking a 400 if a lot piles up in one +// debounce window (e.g. a big Team view mounting at once). +const MAX_LOGINS_PER_FETCH = 100; + +function readSessionStorage(key: string): string | null { + try { + return sessionStorage.getItem(key); + } catch { + return null; + } +} + +function writeSessionStorage(key: string, value: string): void { + try { + sessionStorage.setItem(key, value); + } catch { + // storage blocked/unavailable: the cache just doesn't survive a reload + } +} + +interface StoredNames { + at: number; // epoch ms this was written + names: Record; +} + +function loadCache(): Record { + try { + const stored = readSessionStorage(STORAGE_KEY); + if (!stored) return {}; + const parsed = JSON.parse(stored) as Partial; + if (typeof parsed.at !== 'number' || Date.now() - parsed.at > CACHE_TTL_MS) return {}; + return parsed.names ?? {}; + } catch { + return {}; + } +} + +const raw: Record = loadCache(); +let snapshot: Readonly> = raw; +const listeners = new Set<() => void>(); + +function publish() { + snapshot = { ...raw }; + const stored: StoredNames = { at: Date.now(), names: raw }; + writeSessionStorage(STORAGE_KEY, JSON.stringify(stored)); + for (const fn of listeners) fn(); +} + +// A handful of the dummy fixture's own logins (frontend-v2/public/dummy-pulls.json +// user.logins), given plausible human names so the demo board (?dummy=1) +// shows real-looking display names fully offline. Every other login resolves +// to null, same as a real login GitHub has no public name for. +const DUMMY_NAMES: Record = { + danielbeardsley: 'Daniel Beardsley', + sctice: 'Scott Tice', + jarstelfox: 'Jarred Stelfox', + 'addison-grant': 'Addison Grant', + evannoronha: 'Evan Noronha', + zdmitchell: 'Zach Mitchell', + cdcline: 'Casey Cline', + ardelato: 'Art Delato', +}; + +let pendingBatch = new Set(); +const inFlight = new Set(); +let scheduled = false; + +function chunk(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +function flushBatch() { + scheduled = false; + if (pendingBatch.size === 0) return; + const batch = [...pendingBatch]; + pendingBatch = new Set(); + for (const login of batch) inFlight.add(login); + + for (const group of chunk(batch, MAX_LOGINS_PER_FETCH)) { + fetch(`/user-names?${new URLSearchParams({ logins: group.join(',') })}`) + .then(r => (r.ok ? (r.json() as Promise<{ names: Record }>) : null)) + .then(result => { + for (const login of group) { + raw[login] = result?.names?.[login] ?? null; + inFlight.delete(login); + } + publish(); + }) + .catch(() => { + // Transient failure: drop from in-flight but leave it out of + // `raw` entirely, so it stays "unknown" rather than permanently + // caching a null from a network blip -- a later requestNames() + // call for the same login retries it. + for (const login of group) inFlight.delete(login); + }); + } +} + +function scheduleFetch() { + if (scheduled) return; + scheduled = true; + setTimeout(flushBatch, DEBOUNCE_MS); +} + +/** + * Ask to resolve `logins` to display names. Dedupes against logins already + * known (resolved, even to null) or currently pending/in flight, then folds + * every new login requested within one DEBOUNCE_MS window into a single + * /user-names fetch -- a lane full of rows mounting at once (My work, Review) + * asks once, not once per row. + * + * Dummy mode (?dummy=1) never touches the network: it resolves synchronously + * from a small fixture map so the demo board works fully offline. + */ +export function requestNames(logins: Iterable): void { + const toResolve: string[] = []; + for (const login of logins) { + if (!login) continue; + if (Object.prototype.hasOwnProperty.call(raw, login)) continue; + if (inFlight.has(login) || pendingBatch.has(login)) continue; + toResolve.push(login); + } + if (toResolve.length === 0) return; + + if (isDummy()) { + for (const login of toResolve) raw[login] = DUMMY_NAMES[login] ?? null; + publish(); + return; + } + + for (const login of toResolve) pendingBatch.add(login); + scheduleFetch(); +} + +/** Plain getter for non-React callers, mirroring settings.ts's getSettings(). */ +export function getNames(): Readonly> { + return snapshot; +} + +export function useNames(): Readonly> { + return useSyncExternalStore( + fn => { + listeners.add(fn); + return () => listeners.delete(fn); + }, + () => snapshot + ); +} + +/** The name if known and non-null, else null -- never "" or undefined, so + * callers can render it directly or fall back to the login. */ +export function displayName( + names: Readonly>, + login: string +): string | null { + const name = names[login]; + return typeof name === 'string' && name.length > 0 ? name : null; +} diff --git a/frontend-v2/src/model/query.ts b/frontend-v2/src/model/query.ts index c1ea4bdb..48c95a4e 100644 --- a/frontend-v2/src/model/query.ts +++ b/frontend-v2/src/model/query.ts @@ -17,15 +17,28 @@ import type { DerivedPull } from './status'; * is:blocked status is dev_block or deploy_block * * `me` is the viewer's login, needed only for has:/is: — every other token - * ignores it. + * ignores it. `names` is the optional login → display-name map (model/ + * names.ts): when present, author: and bare terms match the human name too, + * so "metz" finds djmetzle. */ -export function matchesQuery(p: DerivedPull, query: string, me: string): boolean { +export function matchesQuery( + p: DerivedPull, + query: string, + me: string, + names?: Readonly> +): boolean { const terms = query.toLowerCase().split(/\s+/).filter(Boolean); - return terms.every(t => matchTerm(p, t, me)); + return terms.every(t => matchTerm(p, t, me, names)); } -function matchTerm(p: DerivedPull, term: string, me: string): boolean { +function matchTerm( + p: DerivedPull, + term: string, + me: string, + names?: Readonly> +): boolean { const d = p.data; + const authorName = names?.[d.user.login]?.toLowerCase() ?? ''; const i = term.indexOf(':'); if (i > 0) { const key = term.slice(0, i); @@ -38,7 +51,8 @@ function matchTerm(p: DerivedPull, term: string, me: string): boolean { return Number.isFinite(days) && p.ageDays >= days; } if (key === 'repo') return d.repo.toLowerCase().includes(val); - if (key === 'author') return d.user.login.toLowerCase().includes(val); + if (key === 'author') + return d.user.login.toLowerCase().includes(val) || authorName.includes(val); if (key === 'weight') return val.split(',').filter(Boolean).includes(p.weight.toLowerCase()); if (key === 'has') return val === 'action' && rowNote(p, me).action != null; @@ -61,6 +75,7 @@ function matchTerm(p: DerivedPull, term: string, me: string): boolean { d.title.toLowerCase().includes(term) || d.repo.toLowerCase().includes(term) || d.user.login.toLowerCase().includes(term) || + (authorName !== '' && authorName.includes(term)) || d.labels.some(l => l.title.toLowerCase().includes(term)) ); } diff --git a/frontend-v2/src/views/Team.tsx b/frontend-v2/src/views/Team.tsx index 562bbfc3..89fee086 100644 --- a/frontend-v2/src/views/Team.tsx +++ b/frontend-v2/src/views/Team.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react'; import { authorOwnsIt, parked } from '../model/actions'; +import { displayName, useNames } from '../model/names'; import type { DerivedPull } from '../model/status'; import { matchesRegion } from '../model/regions'; import { crSort } from '../model/sort'; @@ -160,7 +161,9 @@ export function Team({ login, <> - {login} + + {nameOf(login) ?? login} + {counts.get(login) ?? 0} {owesMark(login)} , diff --git a/frontend-v2/vite.config.ts b/frontend-v2/vite.config.ts index e273cf78..0b63d91c 100644 --- a/frontend-v2/vite.config.ts +++ b/frontend-v2/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ '/token': 'http://localhost:3000', '/socket.io': { target: 'http://localhost:3000', ws: true }, '/stats-history': 'http://localhost:3000', + '/user-names': 'http://localhost:3000', }, }, }); diff --git a/lib/authentication.js b/lib/authentication.js index b9eed06c..c8166854 100644 --- a/lib/authentication.js +++ b/lib/authentication.js @@ -127,5 +127,6 @@ export default { // route's real handler in app.js (Express runs same-path GET handlers in // registration order), so the query never runs for an unauthenticated request. app.get('/stats-history', auth); + app.get('/user-names', auth); }, }; diff --git a/test/user-names.test.js b/test/user-names.test.js new file mode 100644 index 00000000..88dd130e --- /dev/null +++ b/test/user-names.test.js @@ -0,0 +1,99 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// Fixture config (test/fixtures/config.js) carries a github.* block so +// importing lib/git-manager.js (transitively, via controllers/user-names.js) +// doesn't blow up at module-load time. Nothing here makes a real GitHub call +// -- only the pure/injectable bits are exercised (see test/stats.test.js for +// the same pattern). +const { parseLoginsParam, isValidLogin, isFresh, resolveNames, CACHE_TTL_MS } = await import( + '../controllers/user-names.js' +); + +test('parseLoginsParam trims, drops blanks, and dedupes preserving order', () => { + assert.deepEqual(parseLoginsParam('alice, bob,,alice , carol'), ['alice', 'bob', 'carol']); +}); + +test('parseLoginsParam returns an empty array for empty/missing input', () => { + assert.deepEqual(parseLoginsParam(''), []); + assert.deepEqual(parseLoginsParam(undefined), []); +}); + +test('isValidLogin accepts GitHub logins and the [bot] suffix', () => { + assert.equal(isValidLogin('jarstelfox'), true); + assert.equal(isValidLogin('renovate-bot'), true); + assert.equal(isValidLogin('dependabot[bot]'), true); + assert.equal(isValidLogin('UPPER-Case123'), true); +}); + +test('isValidLogin rejects logins with spaces or injection-y characters', () => { + assert.equal(isValidLogin('foo bar'), false); + assert.equal(isValidLogin('foo/bar'), false); + assert.equal(isValidLogin('foo_bar'), false); + assert.equal(isValidLogin(''), false); + assert.equal(isValidLogin(null), false); +}); + +test('isFresh is false for a missing entry and true within the TTL', () => { + const now = 1_000_000; + assert.equal(isFresh(undefined, now), false); + assert.equal(isFresh({ name: 'Alice', at: now - 1000 }, now), true); +}); + +test('isFresh is false once an entry is older than CACHE_TTL_MS', () => { + const now = 1_000_000; + assert.equal(isFresh({ name: 'Alice', at: now - (CACHE_TTL_MS + 1) }, now), false); +}); + +test('resolveNames reuses fresh cache entries without calling the fetcher', async () => { + const now = 1_000_000; + const cache = new Map([['alice', { name: 'Alice A', at: now - 1000 }]]); + let calls = 0; + const fetcher = async login => { + calls += 1; + return login === 'bob' ? 'Bob B' : null; + }; + + const names = await resolveNames(['alice', 'bob'], { cache, fetcher, now }); + + assert.equal(calls, 1); + assert.deepEqual(names, { alice: 'Alice A', bob: 'Bob B' }); + assert.equal(cache.get('bob').name, 'Bob B'); +}); + +test('resolveNames refetches a stale cache entry and negative-caches a null result', async () => { + const now = 1_000_000; + const cache = new Map([['alice', { name: 'Old Name', at: now - (CACHE_TTL_MS + 1) }]]); + const seen = []; + const fetcher = async login => { + seen.push(login); + return null; // simulates a 404/deleted account + }; + + const names = await resolveNames(['alice'], { cache, fetcher, now }); + + assert.deepEqual(seen, ['alice']); + assert.deepEqual(names, { alice: null }); + assert.equal(cache.get('alice').name, null); +}); + +test('resolveNames caps concurrency without dropping any login', async () => { + const cache = new Map(); + let inFlight = 0; + let maxInFlight = 0; + const logins = Array.from({ length: 12 }, (_, i) => `user${i}`); + const fetcher = async login => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise(resolve => setTimeout(resolve, 1)); + inFlight -= 1; + return `Name ${login}`; + }; + + const names = await resolveNames(logins, { cache, fetcher, concurrency: 5 }); + + assert.ok(maxInFlight <= 5, `expected concurrency <= 5, got ${maxInFlight}`); + for (const login of logins) { + assert.equal(names[login], `Name ${login}`); + } +}); From 44cbe698b1e1ee7c38a597d5cc7c68547936469c Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 13:48:50 -0700 Subject: [PATCH 304/381] Round the age band's growing tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every surface on the board wears a radius except the age baseline: a squared 1px hairline that grows into a squared 8px band on hover. The right tip — the edge you actually see — now rounds like everything else. The left end stays square on purpose: the band is a measurement from the row's edge, a ruler fill, and it tucks under the lane card's own rounded corner. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/styles.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index f14f1b7d..7167a63e 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -333,6 +333,10 @@ label:has(> input:not(:disabled)) { width: 100%; height: 1px; pointer-events: none; + /* the growing tip rounds like every other surface on the board; the left + end stays square — it's a measurement from the row's edge, a ruler + fill, and it tucks under the lane card's own rounded corner */ + border-radius: 0 999px 999px 0; /* only the height animates open; the global prefers-reduced-motion rule at the bottom of this file already zeroes this out */ transition: height 150ms var(--ease-quart); From d7cbe61bb13f4041ac8b84183ca83d6a5b3b37e1 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 13:57:52 -0700 Subject: [PATCH 305/381] One roster: starred people fold into your team MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lists answered one question — "whose work do I watch?" — and a developer meeting both controls (star in the kebab, add-to-team in the picker) had no story about which to use when. The team roster now does both jobs: the Team lens shows your people's combined board, and their PRs lead your Review queue and Needs QA (teamFirst, the renamed starFirst partition — membership is binary, the queue's own score orders teammates among themselves). A one-time migration folds stored starredPeople into myTeam. The roster is framed everywhere as your REVIEW CIRCLE, not the org chart — a cross-team pairing partner belongs here even if you'd never call them a teammate in a meeting; the picker and the Team empty state now say so. The corner star mark means "on your team"; the queue's ranking door documents the float (and that there is no extra ranking between teammates), and each floated row's "why it's up next" line now names it — the strongest override in the system was the one thing the explanation layer never explained. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 3 +- frontend-v2/src/components/Card.tsx | 2 +- frontend-v2/src/components/Legend.tsx | 2 +- frontend-v2/src/components/Row.tsx | 30 ++++---- frontend-v2/src/components/TeamPicker.tsx | 4 ++ frontend-v2/src/components/bits.tsx | 4 +- .../src/components/filters/PeopleFilter.tsx | 26 ++++--- frontend-v2/src/model/regions.ts | 2 +- frontend-v2/src/model/sort.test.ts | 10 +-- frontend-v2/src/model/sort.ts | 16 ++--- frontend-v2/src/settings.ts | 37 +++++----- frontend-v2/src/views/Review.tsx | 32 ++++++--- frontend-v2/src/views/Team.tsx | 71 +++++++++---------- 13 files changed, 124 insertions(+), 115 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 7abb1d75..de67f68d 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -223,7 +223,6 @@ export function App() { ...pulls.map(p => p.data.user.login), ...closed.map(p => p.user.login), ...s.myTeam, - ...s.starredPeople, ...s.hiddenPeople, ]); }, [pulls, closed]); @@ -969,7 +968,7 @@ export function App() { setReveal([]); setDraftsMode(settings.draftsMode); }} - title="clears scope and session toggles; your hidden and starred choices stay" + title="clears scope and session toggles; your team, hidden, and primary-repo choices stay" className="hit pressable rounded-md px-1.5 py-1 text-[13px] text-ink-3 hover:text-brand" > Reset diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index be05cddc..8d622e0c 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -118,7 +118,7 @@ export function CardShell({ /** a bot/app author: the avatar renders as a rounded-square tile instead * of a circle — shape is the whole mark */ bot?: boolean; - /** a tiny marker absolutely-positioned over the avatar (the row's starred- + /** a tiny marker absolutely-positioned over the avatar (the row's teammate * author ★) — a slot rather than an Avatar prop, so this stays a one- * caller concern instead of touching every Avatar call site. */ avatarBadge?: ReactNode; diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 0285c7eb..002dbc9a 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -130,7 +130,7 @@ export function Legend() { } - def="a small corner star marks someone you starred; the larger star seated on your own avatar’s rim is you" + def="a small corner star marks someone on your team; the larger star seated on your own avatar’s rim is you" /> diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 75a13449..e2e66265 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -19,7 +19,7 @@ import { setRepoPref, toggleHiddenPerson, togglePrimaryRepo, - toggleStarredPerson, + toggleTeammate, useSettings, } from '../settings'; import { @@ -412,7 +412,7 @@ function RowActionsKebab({ const repoLabel = shortRepo(repo); const isPrimaryRepo = settings.primaryRepos.includes(repo); const isHiddenRepo = settings.repoPrefs[repo] === 'hide'; - const isStarredAuthor = settings.starredPeople.includes(author); + const isTeammate = settings.myTeam.includes(author); const claimedByMe = claim?.login === me; const item = 'flex w-full items-center gap-2 rounded-md border-0 bg-transparent px-2 py-2 text-left text-xs text-ink-2 hover:bg-muted'; @@ -510,16 +510,16 @@ function RowActionsKebab({ {/* never offered for your own pulls — you can't hide yourself from your own board */} @@ -695,11 +695,11 @@ function RowImpl({ const note = rowNote(pull, opts.me, { claim, turn }); // the wait badge may itself carry a "last commit …" — don't say it twice const showIterating = isIterating(pull) && !(note.context ?? '').includes('last commit'); - // the smallest clean marker for a starred author: a tiny ★ over their - // avatar, so the row itself says "you follow this person" without a - // trip to the kebab menu + // the smallest clean marker for a teammate: a tiny ★ over their avatar, + // so the row itself says "one of your people" without a trip to the + // kebab menu const settings = useSettings(); - const starredAuthor = settings.starredPeople.includes(d.user.login); + const teamAuthor = settings.myTeam.includes(d.user.login); // which of your code regions this pull matched (why it floated to the top) const regions = matchedRegions(pull, settings.codeRegions); // only worth asking the whole-board lookup when this row is stacked but @@ -723,10 +723,10 @@ function RowImpl({ avatarBadge={ // your own rows never wear it: the you-star owns that corner, // and star-on-yourself would double the glyph - starredAuthor && + teamAuthor && d.user.login !== opts.me && ( diff --git a/frontend-v2/src/components/TeamPicker.tsx b/frontend-v2/src/components/TeamPicker.tsx index beb2d496..e1643c13 100644 --- a/frontend-v2/src/components/TeamPicker.tsx +++ b/frontend-v2/src/components/TeamPicker.tsx @@ -106,6 +106,10 @@ export function TeamPicker({ extraBots = EMPTY_BOTS }: { extraBots?: ReadonlySet return (
+

+ Your review circle, not the org chart — anyone whose work you review belongs here. + Teammates’ PRs lead your review queues, and this tab shows their combined board. +

{myTeam.map(login => ( displayName(namesMap, login); const matchesPerson = (login: string, q: string) => login.toLowerCase().includes(q) || (nameOf(login) ?? '').toLowerCase().includes(q); - const starredSet = new Set(settings.starredPeople); + const teamSet = new Set(settings.myTeam); const hiddenSet = new Set(settings.hiddenPeople); const [peopleQuery, setPeopleQuery] = useState(''); @@ -119,7 +119,7 @@ export function PeopleFilter({ )} {filteredShown.map(([login, count]) => { - const isStarred = starredSet.has(login); + const isTeammate = teamSet.has(login); return ( ))} + 0} onClear={() => setStateSel([])} />
); diff --git a/frontend-v2/src/components/filters/WeightFilter.tsx b/frontend-v2/src/components/filters/WeightFilter.tsx index 876d97d1..90749507 100644 --- a/frontend-v2/src/components/filters/WeightFilter.tsx +++ b/frontend-v2/src/components/filters/WeightFilter.tsx @@ -1,7 +1,7 @@ import type { DerivedPull } from '../../model/status'; import { weightFilterKey } from '../../model/status'; import { Popover } from '../Popover'; -import { FilterRow, FilterTrigger, OnlyButton } from './shared'; +import { ClearRow, FilterRow, FilterTrigger, OnlyButton } from './shared'; /** Weight filter option order: lightest to heaviest — mirrors the rail's * own weight-letter read (XS through XL). */ @@ -54,8 +54,19 @@ export function WeightFilter({ setWeightSel([])} + // a lone weight is short enough to BE the badge; more + // collapse to their count ('unknown' can only arrive via a + // weight: query token — the panel doesn't offer it) + badge={ + weightSel.length === 0 + ? null + : weightSel.length === 1 + ? weightSel[0] === 'unknown' + ? '?' + : weightSel[0].toUpperCase() + : String(weightSel.length) + } + title={value ? `Weight · ${value}` : undefined} ariaLabel={`weight filter: ${value ?? 'off'}`} /> )} @@ -77,6 +88,7 @@ export function WeightFilter({ setWeightSel([key])} /> ))} + 0} onClear={() => setWeightSel([])} />
); diff --git a/frontend-v2/src/components/filters/shared.tsx b/frontend-v2/src/components/filters/shared.tsx index 97a05a43..08323c8a 100644 --- a/frontend-v2/src/components/filters/shared.tsx +++ b/frontend-v2/src/components/filters/shared.tsx @@ -1,63 +1,70 @@ import type { ChangeEvent, ReactNode } from 'react'; -import { ChevronDown, X } from 'lucide-react'; +import { ChevronDown } from 'lucide-react'; import { Icon } from '../Icon'; import type { PopoverTriggerProps } from '../Popover'; +import { CornerBadge, QuietButton } from '../bits'; /** - * The one filter-bar trigger: a quiet text-level control, not a bordered - * button. The bar's normal state is "a few filters always on", so an active - * filter can't wear highlight chrome — the whole ladder is typographic: - * inactive = muted word, active = the dimension's VALUE named in ink with a - * small clear ×. The trigger is the chip; there is no second pill restating - * it elsewhere. (No leading icons either: six pictograms said less than the - * six words, and cost a row of decoding.) + * The one filter-bar trigger: a quiet text-level control whose geometry NEVER + * changes. The dimension name is the whole trigger; the active state rides as + * a small corner badge of 1–3 characters — a count, or the value itself when + * it fits (a lone weight's "S"), or a −count for an excluding selection — so + * narrowing the board can't reflow the bar. Full words live in the hover + * title and aria-label; the panel behind the click is the detail view, and + * clearing lives in the panel too (ClearRow), not as an appearing ×. */ export function FilterTrigger({ t, label, - value, - onClear, + badge, + badgeTone = 'brand', + active = false, + title, ariaLabel, }: { t: PopoverTriggerProps; /** the dimension name, always visible: Repos, People, Weight… */ label: string; - /** the active selection, named ("XS, S"); null/empty = inactive */ - value?: string | null; - /** clears just this dimension (renders the × beside the trigger) */ - onClear?: () => void; + /** 1–3 character summary of the active state; null/empty = no badge */ + badge?: string | null; + /** 'brand' = a narrowing you chose; 'quiet' = standing info (the hidden + * ledger's count) */ + badgeTone?: 'brand' | 'quiet'; + /** a standing non-badge state (e.g. "showing hidden") — carried by color + * only, so the trigger still never moves */ + active?: boolean; + /** full-words active state for the hover title (the badge abbreviates) */ + title?: string; ariaLabel: string; }) { return ( - - - {value && onClear && ( - - )} - + + ); +} + +/** + * The panel-footer clear for one dimension — the home the bar's per-trigger × + * moved into when triggers went geometry-constant. Renders nothing while the + * dimension is inactive, so panels only offer what would do something. + */ +export function ClearRow({ active, onClear }: { active: boolean; onClear: () => void }) { + if (!active) return null; + return ( +
+ Clear +
); } @@ -159,3 +166,30 @@ export function OnlyButton({ onClick }: { onClick: () => void }) { ); } + +/** + * "only"'s inverse, People rows only: flip this person into the "everyone + * except" scope. An excluded row's button STANDS (visible without hover, + * beside the struck-through name) because it records a choice; clicking it + * again re-includes them. + */ +export function ExceptButton({ on, onClick }: { on: boolean; onClick: () => void }) { + return ( + + ); +} From f5302ea04200617ef3da23319eb4eaa196abebf5 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 16:20:38 -0700 Subject: [PATCH 318/381] People rows learn "except"; selection chips go quiet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "everyone except" scope survived the tri-state chips' retirement with no UI writer left. Each People row now carries an "except" quick-action beside "only": plain click excludes that person (struck name, standing button, unchecked box), clicking again re-includes, and per-login the allow and exclude lanes stay mutually exclusive. The trigger badge reads −N for an excluding selection. The Team lens's selected chips also drop their brand ring for the same quiet bg-secondary fill the lens tabs and pinned chips use — one active vocabulary everywhere, per the owner's "visually loud" call. Co-Authored-By: Claude Fable 5 --- .../src/components/filters/PeopleFilter.tsx | 114 +++++++++++------- frontend-v2/src/views/Team.tsx | 50 ++------ 2 files changed, 82 insertions(+), 82 deletions(-) diff --git a/frontend-v2/src/components/filters/PeopleFilter.tsx b/frontend-v2/src/components/filters/PeopleFilter.tsx index e09106f6..9941ff9c 100644 --- a/frontend-v2/src/components/filters/PeopleFilter.tsx +++ b/frontend-v2/src/components/filters/PeopleFilter.tsx @@ -4,29 +4,31 @@ import { displayName, useNames } from '../../model/names'; import type { Scope } from '../../prefs'; import { toggleHiddenPerson, toggleTeammate, useSettings } from '../../settings'; import { usePulldasher } from '../../store'; -import type { BoardTeam } from '../../types'; import { Avatar, QuietButton, StarMark } from '../bits'; import { Popover } from '../Popover'; -import { FilterRow, FilterSearch, FilterTrigger, OnlyButton } from './shared'; +import { + ClearRow, + ExceptButton, + FilterRow, + FilterSearch, + FilterTrigger, + OnlyButton, +} from './shared'; /** - * The people filter: team preset chips, then a searchable author list where - * scope (on my board), star (floats to the front of my queues), and hide - * (off my board, period) live side by side on each row — the same - * scope/hide-together layout RepoFilter uses for repos. "Your team" (your - * own per-browser roster, prepended to the org's teams in app.tsx) gets a - * brand border instead of the ★ prefix: the star glyph already means - * something specific elsewhere on this row (star a person), so reusing it as - * decoration on the team chip would read as "this team is starred." + * The people filter: a searchable author list where scope (on my board), + * except (everyone but them), star (floats to the front of my queues), and + * hide (off my board, period) live side by side on each row — the same + * scope/hide-together layout RepoFilter uses for repos. Whole-team narrowing + * lives in the pinned saved searches, not here: every roster already derives + * one, so this panel stays a per-person surface. */ export function PeopleFilter({ pulls, - teams, scope, setScope, }: { pulls: DerivedPull[]; - teams: BoardTeam[]; scope: Scope; setScope: (next: Scope) => void; }) { @@ -63,15 +65,40 @@ export function PeopleFilter({ commit(cur, all); }; const included = (login: string) => !scope.authors.length || scope.authors.includes(login); + // the "everyone except" lane: per-login, mutually exclusive with the + // allow-list (excluding someone pulls them out of authors, and vice versa) + const exclude = (login: string) => + setScope({ + ...scope, + authors: scope.authors.filter(l => l !== login), + notAuthors: [...new Set([...scope.notAuthors, login])], + }); + const include = (login: string) => + setScope({ ...scope, notAuthors: scope.notAuthors.filter(l => l !== login) }); - // the trigger names only what narrows the board: one person by login, - // more as a count. Hidden counts read in the hidden-PR ledger, not here. + // full words for the hover title / aria; the badge abbreviates to a count + // (or a −count when the selection is an exclusion) const value = scope.authors.length === 0 ? null : scope.authors.length === 1 ? scope.authors[0] : `${scope.authors.length} people`; + const excludingWords = + scope.notAuthors.length === 0 + ? null + : `excluding ${ + scope.notAuthors.length === 1 + ? scope.notAuthors[0] + : `${scope.notAuthors.length} people` + }`; + const words = + value && excludingWords ? `${value} · ${excludingWords}` : (value ?? excludingWords); + const badge = scope.authors.length + ? String(scope.authors.length) + : scope.notAuthors.length + ? `−${scope.notAuthors.length}` + : null; const filteredShown = shownAuthors.filter(([login]) => matchesPerson(login, peopleQuery.toLowerCase()) @@ -91,57 +118,50 @@ export function PeopleFilter({ setScope({ ...scope, authors: [] })} - ariaLabel={`people filter: ${value ?? 'off'}`} + badge={badge} + title={words ? `People · ${words}` : undefined} + ariaLabel={`people filter: ${words ?? 'off'}`} /> )} > - {teams.length > 0 && ( -
- {teams.map(t => { - const isYours = !!t.personal; - return ( - - ); - })} -
- )} {filteredShown.map(([login, count]) => { const isTeammate = teamSet.has(login); + const excluded = scope.notAuthors.includes(login); return ( - setScope({ ...scope, authors: [login] })} /> + setScope({ ...scope, authors: [login], notAuthors: [] })} + /> + (excluded ? include(login) : exclude(login))} + />
); diff --git a/frontend-v2/src/views/Team.tsx b/frontend-v2/src/views/Team.tsx index 88f2e420..674d1748 100644 --- a/frontend-v2/src/views/Team.tsx +++ b/frontend-v2/src/views/Team.tsx @@ -7,7 +7,6 @@ import { matchesRegion } from '../model/regions'; import { crSort } from '../model/sort'; import { teamBuckets } from '../model/team'; import { addTeam, DEFAULT_TEAM_NAME, myPeople, removeTeam, useSettings } from '../settings'; -import type { BoardTeam } from '../types'; import { Avatar, EmptyState, QuietButton } from '../components/bits'; import { Icon } from '../components/Icon'; import { Fold, FoldRows, Lane, laneShown, RestGroup, SubDoor } from '../components/Lane'; @@ -19,8 +18,8 @@ import { WordGroupRows } from '../components/WordGroups'; /** * The people tab (named Team): every board keyed by who wrote the work, * starting with yours. Your rosters are the pinned first row; behind them - * sits the whole directory — config.json teams and every author on the - * board — so any avatar click anywhere lands here on that person's page. + * sits the whole directory — every author on the board — so any avatar + * click anywhere lands here on that person's page. * * Selection here IS the authors filter: clicking a person or a team chip * writes the same scope the filter bar's People picker edits, so what you @@ -31,7 +30,6 @@ import { WordGroupRows } from '../components/WordGroups'; export function Team({ pulls, allPulls, - teams, selected, onSelect, opts, @@ -42,7 +40,6 @@ export function Team({ /** unscoped pool (for chip counts and owed re-stamps — filters must not * make the directory lie about someone's real load) */ allPulls: DerivedPull[]; - teams: BoardTeam[]; /** the authors scope (app.tsx) — this lens's selection is that filter */ selected: string[]; onSelect: (logins: string[]) => void; @@ -81,9 +78,11 @@ export function Team({ // ---- selection: this lens reads and writes the authors scope ---- const sameSet = (a: string[], b: string[]) => a.length === b.length && b.every(m => a.includes(m)); - // a selection that is exactly some team's roster earns that team's name - const selTeam = selected.length ? teams.find(t => sameSet(selected, t.members)) : undefined; - const explicitTeam = selTeam?.team ?? null; + // a selection that is exactly some roster earns that roster's name + const selTeam = selected.length + ? personalTeams.find(t => sameSet(selected, t.members)) + : undefined; + const explicitTeam = selTeam?.name ?? null; const selectedPerson = !selTeam && selected.length === 1 ? selected[0] : null; // home = nothing selected: your rosters' aggregate board, unfiltered const home = selected.length === 0; @@ -119,9 +118,6 @@ export function Team({ ); const regionMatches = home ? crSort(reviewable.filter(p => matchesRegion(p, codeRegions))) : []; const owed = selectedPerson ? (owes.get(selectedPerson) ?? []) : []; - const memberTeam = selectedPerson - ? teams.find(t => !t.personal && t.members.includes(selectedPerson))?.team - : null; const shipping = theirs.filter(p => ['ready', 'needs_qa'].includes(p.status)).length; // the directory: busiest authors lead, hidden ones drop out unless the @@ -135,7 +131,6 @@ export function Team({ // the door opens itself when it must: no team yet (the directory is the // only content), or the current pick lives outside your team (hiding the // chip that explains the board would orphan it) - const personalNames = new Set(personalTeams.map(t => t.name)); const outsidePick = selected.some(l => !yourSet.has(l)); const directoryShown = personalTeams.length === 0 || (directoryChoice ?? outsidePick); @@ -152,10 +147,8 @@ export function Team({ onClick={onPick} aria-pressed={active} title={title} - className={`pressable inline-flex items-center gap-1.5 rounded-lg border bg-surface py-[5px] pr-2.5 pl-1.5 text-[13px] font-medium text-ink-2 ${ - active - ? 'border-brand shadow-[0_0_0_1px_var(--brand)] hover:bg-muted' - : 'border-line hover:bg-muted' + className={`pressable inline-flex items-center gap-1.5 rounded-lg border border-line py-[5px] pr-2.5 pl-1.5 text-[13px] font-medium text-ink-2 ${ + active ? 'bg-secondary' : 'bg-surface hover:bg-muted' }`} > {label} @@ -273,26 +266,10 @@ export function Team({
)} - {/* the rest of the directory: config teams, then everyone else — - behind the Everyone door unless it must stand (see above) */} + {/* the rest of the directory: everyone on the board who isn't on a + roster — behind the Everyone door unless it must stand (see above) */} {directoryShown && (
- {teams - .filter(t => !t.personal) - .map(t => - chip( - `team:${t.team}`, - <> - {t.team} - - {allPulls.filter(p => t.members.includes(p.data.user.login)).length} - - , - sameSet(selected, t.members), - e => pickGroup(t.members, e), - `narrow the board to ${t.team} — shift-click to add or remove the whole team` - ) - )} {shownLogins.map(login => personChip(login))} {!allPeople && logins.length > 24 && (
)} - {(home || (explicitTeam && personalNames.has(explicitTeam))) && scopeHides > 0 && ( + {(home || explicitTeam) && scopeHides > 0 && (
filters hide {scopeHides} more
)} - {(home || (explicitTeam && personalNames.has(explicitTeam))) && + {(home || explicitTeam) && personalTeams.length > 0 && reviewable.length === 0 && stamped.length === 0 && From f9d0f32a3669c290a6592961910e92ad9069e2d8 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 16:20:39 -0700 Subject: [PATCH 319/381] Retire config.json teams: rosters are the only teams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two kinds of teams survived into v2 — org-defined config.json teams and personal rosters — rendering as parallel chip rows in the Team lens and preset buttons in the People filter, with different powers (config teams couldn't be pinned, renamed, or lead your queues). The owner's call: saved searches already cover "filter by team", so the org layer goes. The People filter drops its preset chips (per-person surface now), the Team lens reads personal rosters only, SiteConfig stops parsing `teams` (an older config.json's key is ignored), and the Team/BoardTeam types disappear. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/prefs.ts | 21 +++++---------------- frontend-v2/src/types.ts | 13 ------------- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/frontend-v2/src/prefs.ts b/frontend-v2/src/prefs.ts index 42dc8a08..eb4db305 100644 --- a/frontend-v2/src/prefs.ts +++ b/frontend-v2/src/prefs.ts @@ -1,6 +1,5 @@ import type { Weight } from './model/status'; import { createPersistentStore } from './storage'; -import type { Team } from './types'; const WEIGHTS: ReadonlySet = new Set(['XS', 'S', 'M', 'L', 'XL']); @@ -37,8 +36,6 @@ export function useScope(): [Scope, (next: Scope) => void] { } export interface SiteConfig { - /** team → members, for the People lens chips and scope presets */ - teams: Team[]; /** bot logins beyond the `[bot]` suffix GitHub Apps carry */ bots: string[]; /** label title → weight bucket. A PR carrying one of these labels takes @@ -49,30 +46,22 @@ export interface SiteConfig { /** * Deployment-specific config, served as a static file next to the app (see - * config.example.json). Absent file means no team features and suffix-only - * bot detection; everything else still works. + * config.example.json). Absent file means suffix-only bot detection and + * heuristic-only weights; everything else still works. A `teams` key in an + * older config.json is simply ignored — org teams retired in favor of + * personal rosters and their pinned saved searches. */ export async function loadSiteConfig(): Promise { - const none: SiteConfig = { teams: [], bots: [], weightLabels: {} }; + const none: SiteConfig = { bots: [], weightLabels: {} }; try { const res = await fetch(`${import.meta.env.BASE_URL}config.json`); if (!res.ok) return none; const raw = (await res.json()) as { - teams?: unknown; bots?: unknown; weightLabels?: unknown; }; - const teams = Array.isArray(raw.teams) ? raw.teams : []; const bots = Array.isArray(raw.bots) ? raw.bots : []; return { - // one malformed entry must not white-screen the whole app - teams: teams.filter( - (t): t is Team => - !!t && - typeof (t as Team).team === 'string' && - Array.isArray((t as Team).members) && - (t as Team).members.every(m => typeof m === 'string') - ), bots: bots.filter((b): b is string => typeof b === 'string'), weightLabels: parseWeightLabels(raw.weightLabels), }; diff --git a/frontend-v2/src/types.ts b/frontend-v2/src/types.ts index 0e5de076..2034d431 100644 --- a/frontend-v2/src/types.ts +++ b/frontend-v2/src/types.ts @@ -138,16 +138,3 @@ export interface TokenResponse { user: string; title: string; } - -/** GitHub team → members, for the Teams lens (served as static config). */ -export interface Team { - team: string; - members: string[]; -} - -/** a Team as the board renders it: config teams plus the user's own personal - * rosters (settings.ts `teams`), the latter flagged so views can pin, style, - * and route them differently from org teams. */ -export interface BoardTeam extends Team { - personal?: boolean; -} From 0eba1ac48334b2026c8a2bd239968ed29fe52692 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 16:32:14 -0700 Subject: [PATCH 320/381] Split bits.tsx into identity, age, and pips modules bits.tsx had grown into a 1,200-line bucket holding three complete domains next to the generic scraps: the author-identity system (the silhouette-and-the-star avatar marks), the age ruler (AgeBaseline / AgeStamp), and the rail's pip family (Pips / SigPips / CiStatus). Pure cut-and-paste moves into components/identity.tsx, age.tsx, and pips.tsx with imports updated everywhere; bits.tsx keeps the small shared controls and vocabulary. No behavior changes. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Card.tsx | 5 +- frontend-v2/src/components/ClosedRow.tsx | 3 +- frontend-v2/src/components/Row.tsx | 15 +- frontend-v2/src/components/StatePopover.tsx | 3 +- frontend-v2/src/components/TeamPicker.tsx | 2 +- frontend-v2/src/components/age.tsx | 218 +++++ frontend-v2/src/components/bits.tsx | 846 +----------------- .../src/components/filters/PeopleFilter.tsx | 3 +- .../src/components/filters/RepoFilter.tsx | 3 +- frontend-v2/src/components/identity.tsx | 208 +++++ frontend-v2/src/components/pips.tsx | 397 ++++++++ frontend-v2/src/views/Team.tsx | 3 +- frontend-v2/src/views/stats/parts.tsx | 2 +- 13 files changed, 854 insertions(+), 854 deletions(-) create mode 100644 frontend-v2/src/components/age.tsx create mode 100644 frontend-v2/src/components/identity.tsx create mode 100644 frontend-v2/src/components/pips.tsx diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx index 8d622e0c..3a046750 100644 --- a/frontend-v2/src/components/Card.tsx +++ b/frontend-v2/src/components/Card.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; -import { Avatar, PullTitleLink } from './bits'; +import { PullTitleLink } from './bits'; +import { Avatar } from './identity'; /** * The one row every lens renders. Two densities of the same content: @@ -112,7 +113,7 @@ export function CardShell({ stretch?: boolean; compact?: boolean; /** the viewer authored this pull: the avatar wears the you-mark (the - * corner star seated in a bite on its rim — see bits.tsx's identity + * corner star seated in a bite on its rim — see identity.tsx's identity * system note) */ own?: boolean; /** a bot/app author: the avatar renders as a rounded-square tile instead diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx index 0a99fe7e..834a9a89 100644 --- a/frontend-v2/src/components/ClosedRow.tsx +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -1,7 +1,8 @@ import { memo } from 'react'; import type { PullData } from '../types'; import { ago, closedEpoch } from '../format'; -import { Avatar, PullTitleLink, RepoRef } from './bits'; +import { PullTitleLink, RepoRef } from './bits'; +import { Avatar } from './identity'; import { ClosedBadgeTrigger } from './StatePopover'; /** diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 395db983..599ed343 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -34,18 +34,11 @@ import { unsnoozePull, usePulldasher, } from '../store'; -import { - AgeStamp, - AgeBaseline, - CiStatus, - DiffSize, - QuietButton, - RepoRef, - SigPips, - StarMark, - WEIGHT_WORD, -} from './bits'; +import { AgeStamp, AgeBaseline } from './age'; +import { DiffSize, QuietButton, RepoRef, WEIGHT_WORD } from './bits'; import { CardShell } from './Card'; +import { StarMark } from './identity'; +import { CiStatus, SigPips } from './pips'; import { Icon } from './Icon'; import { StatePopover } from './StatePopover'; import { Popover } from './Popover'; diff --git a/frontend-v2/src/components/StatePopover.tsx b/frontend-v2/src/components/StatePopover.tsx index b3bb68c5..6ad5230e 100644 --- a/frontend-v2/src/components/StatePopover.tsx +++ b/frontend-v2/src/components/StatePopover.tsx @@ -4,7 +4,8 @@ import type { Claim, RowNote } from '../model/actions'; import { rowNote } from '../model/actions'; import type { PullData } from '../types'; import { ago, closedEpoch, epoch, githubUrl, issueUrl, signatureUrl } from '../format'; -import { Avatar, ClosedBadge, STATUS_LABEL } from './bits'; +import { ClosedBadge, STATUS_LABEL } from './bits'; +import { Avatar } from './identity'; import { Popover } from './Popover'; interface FeedbackSource { diff --git a/frontend-v2/src/components/TeamPicker.tsx b/frontend-v2/src/components/TeamPicker.tsx index fc1f68c5..38dcf338 100644 --- a/frontend-v2/src/components/TeamPicker.tsx +++ b/frontend-v2/src/components/TeamPicker.tsx @@ -4,8 +4,8 @@ import { displayName, useNames } from '../model/names'; import { isBotLogin } from '../model/visibility'; import { removeTeam, renameTeam, toggleTeammate, useSettings } from '../settings'; import { usePulldasher } from '../store'; -import { Avatar } from './bits'; import { FilterSearch } from './filters/shared'; +import { Avatar } from './identity'; import { Icon } from './Icon'; const SUGGESTION_CAP = 12; diff --git a/frontend-v2/src/components/age.tsx b/frontend-v2/src/components/age.tsx new file mode 100644 index 00000000..fda6b27d --- /dev/null +++ b/frontend-v2/src/components/age.tsx @@ -0,0 +1,218 @@ +import { ROT_DAYS, STARVE_DAYS } from '../model/status'; +import { ago } from '../format'; +import { Popover } from './Popover'; + +/** + * The age popover's body: "opened X ago", "last activity Y ago", and (only + * when the row's age line is actually drawn) the line's own relative-to- + * the-oldest explanation. Shared by AgeStamp (the numeral, every row) and + * AgeBaseline (the hoverable band, only aging rows) so the two doors into + * the same fact can never drift apart — one copy, two triggers. + */ +function AgePopoverBody({ + createdAt, + updatedAt, + explainLine, +}: { + /** epoch secs the pull opened */ + createdAt: number; + /** epoch secs of the last activity */ + updatedAt: number; + /** the caller decides: the numeral shows on every row and only adds this + * once its own age crosses warnDays, the band only ever renders once + * that's already true, so it always passes it. */ + explainLine: boolean; +}) { + return ( + <> + + opened {ago(createdAt)} ago + + + last activity {ago(updatedAt)} ago + + {explainLine && ( + + the grey line under this row is its age, relative to the board’s oldest open pull + + )} + + ); +} + +/** + * Age as the row's own baseline: a 1px hairline along the bottom edge — a + * tinted stretch of the divider the row already has, not a drawn bar. + * RELATIVE, not thresholded: the board's longest-open pull sets the full + * track and every row is a fraction of it, so the line answers "how long + * has this waited, relative to what waiting looks like here." The tint + * deepens with that same fraction, ultra-light ink for the merely-aging + * through full ink-3 for the oldest — age is a quiet fact in grey, never + * a colored alarm (an amber version shipped for an hour and was the + * loudest thing on the board; urgency belongs to the queue's ranking and + * the numeral's weight). Silent below the aging threshold — a healthy + * young row draws nothing. + * + * The line is also a door now, not pure geometry: a 10px invisible hit strip + * (`.pd-age-track`/`.pd-age-hit` in styles.css), sized to the SAME fraction + * as the visible line so a 1px target doesn't need pixel-hunting, opens the + * same age popover the numeral shows on hover or focus and grows the line to + * an 8px band while it's open. At rest it's pixel-identical to the old + * static hairline. + */ +export function AgeBaseline({ + ageDays, + createdAt, + updatedAt, + warnDays = STARVE_DAYS, + maxAgeDays = 1, + quiet, +}: { + ageDays: number; + /** epoch secs the pull opened — feeds the shared age popover body */ + createdAt: number; + /** epoch secs of the last activity — feeds the shared age popover body */ + updatedAt: number; + warnDays?: number; + /** the board's longest-open pull — the 100% mark of the track */ + maxAgeDays?: number; + /** drafts and holds age on purpose: draw nothing */ + quiet?: boolean; +}) { + if (quiet || ageDays < warnDays) return null; + const t = Math.min(ageDays / Math.max(maxAgeDays, 1), 1); + // tint rides the same fraction as length: ~30% border-grey at the + // gate, the full border color on the board's oldest (owner call: the + // ink ramp read too dark against the row divider it extends) + const inkPct = Math.round(30 + 70 * t); + return ( + // the percentage width lives on this outer track (not the line itself, + // see styles.css): the line and its taller hit area both fill 100% of + // it, so hovering anywhere along the row's actual age fraction — never + // past it — opens the door. + + ( + + )} + > + + + + ); +} + +/** + * The age slot: hours under a day, then days, with both clocks in the + * popover. Hours matter here: in three months of real history, 62% of + * pulls merged same-day, so "0d" was a dead signal for most of the live + * board. It rides at the rail's far right, capping the row — and it + * WHISPERS: its resting color is the age line's own border tint, rising to + * ink on row hover (see styles.css .pd-age-num). Age's salience is carried + * once, by the baseline built for continuous gradation; the numeral is the + * label you consult, not a second alarm. Font weight still steps at the + * warn/rot thresholds so the hover read carries the urgency. + * + * `display` picks the clock the numeral shows (opened vs last update); the + * urgency weight always follows the OPENED clock — how long a pull has been + * open is the truth the board ranks by, whichever number the user prefers + * to read. + */ +export function AgeStamp({ + ageDays, + createdAt, + updatedAt, + quiet, + warnDays = STARVE_DAYS, + rotDays = ROT_DAYS, + inline, + display = 'opened', +}: { + ageDays: number; + /** epoch secs the pull opened */ + createdAt: number; + /** epoch secs of the last activity */ + updatedAt: number; + /** drafts and holds age on purpose: no urgency weight */ + quiet?: boolean; + /** heavier type at/after this many days (user setting; defaults to the model's) */ + warnDays?: number; + /** heaviest type at/after this many days */ + rotDays?: number; + /** true when it's embedded in a flowing meta line rather than the rail's + * fixed-width column — drops the w-7/text-right slot in favor of plain + * inline text. */ + inline?: boolean; + /** which clock the numeral shows (settings.ageDisplay) */ + display?: 'opened' | 'updated'; +}) { + const heft = quiet + ? '' + : ageDays >= rotDays + ? 'font-semibold' + : ageDays >= warnDays + ? 'font-medium' + : ''; + const shownEpoch = display === 'updated' ? updatedAt : createdAt; + const shownDays = + display === 'updated' + ? Math.max(0, Math.floor((Date.now() / 1000 - updatedAt) / 86400)) + : ageDays; + const text = shownDays === 0 ? ago(shownEpoch) : `${shownDays}d`; + // a popover, not a title: the second clock (last activity) exists nowhere + // else on the row, and a native tooltip is mouse-only — this way touch + // taps it and keyboard reads it from the aria-label + return ( + ( + + )} + > + = warnDays} + /> + + ); +} diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx index 0b155972..48a37c63 100644 --- a/frontend-v2/src/components/bits.tsx +++ b/frontend-v2/src/components/bits.tsx @@ -1,36 +1,19 @@ import { useEffect, useState } from 'react'; -import type { - ButtonHTMLAttributes, - CSSProperties, - KeyboardEvent as ReactKeyboardEvent, - ReactNode, -} from 'react'; -import { Check, CircleDot, Star, X } from 'lucide-react'; -import { - type DerivedPull, - headStatuses, - ROT_DAYS, - STARVE_DAYS, - type Status, - type Weight, -} from '../model/status'; -import type { CommitStatus, Signature } from '../types'; -import { - ago, - epoch, - githubAvatarUrl, - githubProfileUrl, - githubUrl, - loginHue, - n, - shortRepo, - signatureUrl, -} from '../format'; +import type { ButtonHTMLAttributes, KeyboardEvent as ReactKeyboardEvent } from 'react'; +import type { Status, Weight } from '../model/status'; +import { githubUrl, shortRepo } from '../format'; import { getSettings } from '../settings'; -import { displayName, requestNames, useNames } from '../model/names'; -import { Icon } from './Icon'; import { Popover } from './Popover'; +/** + * The board's small generic scraps: status label/dot vocabulary, weight + * words, corner badges, buttons, diff sizes, the title link + repo ref, and + * the plain form controls (Segmented, Switch, EmptyState). The three domain + * systems that used to share this file — author identity (the silhouette + * and the star), the age ruler, and the sign-off/CI pip family — moved to + * identity.tsx, age.tsx, and pips.tsx. + */ + export const STATUS_LABEL: Record = { ready: 'Ready to merge', // only at the ready gate: fully signed off, CI is the one thing left. @@ -128,207 +111,6 @@ export function ClosedBadge({ merged, inline }: { merged: boolean; inline?: bool ); } -/** - * The board's author-identity system — the silhouette and the star: - * - * - SHAPE answers "person or machine": people are circles, bots (GitHub - * Apps and config-listed machine accounts) are rounded squares, the - * app-tile idiom Slack and GitHub already taught. No glyph, no hue — the - * outline is the mark, so it reads at 16px and in peripheral vision. - * - The BOTTOM-RIGHT CORNER answers "what is this person to you", in one - * glyph: a small star on a teammate (your review circle), and a larger star seated on - * the rim of your own avatar with a bite masked out of the face, so your - * silhouette is visibly broken. You are the star vocabulary's largest - * case — escalated by size and form, never by fill. (The ringed seal and - * the v1 star-coin both died here: a halo reads as focus, a coin loses - * the face.) - * - Identity, never state: none of it animates or changes with PR status. - */ - -/** The you-mark's geometry, shared by the face's bite mask and the star - * seated in it: star size, the star's center relative to the avatar box, - * and the moat (bite) radius. One source so mask and glyph can't drift. */ -function youStarGeometry(size: number) { - const star = size >= 20 ? 13 : 11; - const offset = size >= 20 ? 3 : 2.5; - const center = size + offset - star / 2; - return { star, offset, center, moat: star / 2 + 2 }; -} - -/** - * The face itself: the GitHub picture over the deterministic-hue initials. - * The initials sit underneath and show through the instant the image 404s - * (bots, deleted accounts, an offline CDN) — so we always render something, - * never a broken-image glyph. - */ -function AvatarFace({ - login, - size, - shape = 'circle', - bitten = false, -}: { - login: string; - size: number; - /** 'square' = a bot/app tile (~18% corner radius); 'circle' = a person */ - shape?: 'circle' | 'square'; - /** notch the bottom-right rim out of the face so the you-star can seat - * IN the silhouette rather than merely on top of it */ - bitten?: boolean; -}) { - const [broken, setBroken] = useState(false); - // OKLCH holds perceived lightness constant across the hue wheel — the old - // hsl(h 45% 45%) made yellow-green logins illegible under white text - const style: CSSProperties = { - background: `oklch(0.48 0.09 ${loginHue(login)})`, - width: size, - height: size, - fontSize: Math.round(size * 0.42), - borderRadius: shape === 'square' ? Math.max(3, Math.round(size * 0.18)) : 9999, - }; - if (bitten) { - const g = youStarGeometry(size); - // transparent moat, not a painted stroke: the bite stays correct over - // any row background (hover, fresh-flash), which the old seal's - // surface-colored outline never quite did - const mask = `radial-gradient(circle at ${g.center}px ${g.center}px, transparent ${g.moat - 0.25}px, #000 ${g.moat + 0.25}px)`; - style.WebkitMaskImage = mask; - style.maskImage = mask; - } - return ( - - {login.slice(0, 2)} - {!broken && ( - setBroken(true)} - /> - )} - - ); -} - -/** The hover card behind a clickable avatar: the picture bigger, the human - * name when GitHub has one (resolved through model/names.ts's cached - * server-side lookup), the handle, and a jump to their GitHub profile. */ -function PersonCard({ login }: { login: string }) { - const names = useNames(); - useEffect(() => requestNames([login]), [login]); - const name = displayName(names, login); - return ( - - ); -} - -export function Avatar({ - login, - size = 22, - onClick, - shape = 'circle', - you = false, -}: { - login: string; - size?: number; - onClick?: (login: string) => void; - /** 'square' for bot/app authors — see the identity-system note above */ - shape?: 'circle' | 'square'; - /** the viewer's own avatar: wears the seated corner star + bitten rim */ - you?: boolean; -}) { - const g = you ? youStarGeometry(size) : null; - // the star rides INSIDE whatever scales on hover, so the bite in the face - // and the star seated in it can never fall out of registration - const face = ( - - - {g && ( - - - - )} - - ); - if (!onClick) { - return ( - - {face} - - ); - } - // A clickable avatar keeps its one-click "filter to this person" gesture - // (the trigger's own onClick), and grows a hover preview card on top — the - // same hover-open/pin discipline AgeStamp and the ledger use. Hover is a - // supplement (the picture bigger + a GitHub link); the click action stays - // fully keyboard- and touch-reachable. - return ( - ( - - )} - > - - - ); -} - -/** - * The one star mark every "primary repo" / "teammate" toggle shares - * (Row's kebab menu, RepoFilter, PeopleFilter, RepoManager): filled when on, - * outline when off, same lucide glyph everywhere instead of five hand-rolled - * ★/☆ copies. - */ -export function StarMark({ on, size = 14 }: { on: boolean; size?: number }) { - return ; -} - export const WEIGHT_WORD: Record = { XS: 'very light', S: 'light', @@ -356,610 +138,6 @@ export function DiffSize({ additions, deletions }: { additions: number; deletion ); } -const CI_STATE_META: Record< - CommitStatus['data']['state'], - { icon: typeof Check; color: string; word: string } -> = { - success: { icon: Check, color: 'var(--ok)', word: 'passed' }, - failure: { icon: X, color: 'var(--bad)', word: 'failed' }, - error: { icon: X, color: 'var(--bad)', word: 'errored' }, - pending: { icon: CircleDot, color: 'var(--slate)', word: 'running' }, -}; - -const isRedCheck = (s: CommitStatus) => s.data.state === 'failure' || s.data.state === 'error'; - -// failures first, then the still-running ones, then the greens; alphabetical -// within a tier so the list is stable run to run -function ciRank(s: CommitStatus): number { - return isRedCheck(s) ? 0 : s.data.state === 'pending' ? 1 : 2; -} - -/** completed − started, when both timestamps are known, as a terse "45s"/"6m". */ -function ciDuration(s: CommitStatus): string | null { - const { started_at, completed_at } = s.data; - if (started_at == null || completed_at == null) return null; - const secs = Math.max(0, completed_at - started_at); - return secs < 60 ? `${Math.round(secs)}s` : `${Math.round(secs / 60)}m`; -} - -/** - * CI in the sign-off family: the machine is a reviewer, so it wears the same - * label + circle-mark anatomy as CR and QA. Only a failure earns ink at rest - * (a red X disc plus the failing count); a passing or still-running check - * renders in a reserved, invisible slot and is revealed on row hover — the - * same "green only on hover" physics the old segmented bar had, now in one - * vocabulary. The trigger opens the per-check list: state, name, duration, - * and a link for each. A fixed-width placeholder (not null) stands in for a - * pull with no checks, so the rail's other slots don't shift row-to-row. - */ -export function CiStatus({ pull }: { pull: DerivedPull }) { - const checks = - pull.ci === 'none' - ? [] - : [...headStatuses(pull.data)].sort( - (a, b) => ciRank(a) - ciRank(b) || a.data.context.localeCompare(b.data.context) - ); - if (!checks.length) return ; - - const failing = checks.filter(isRedCheck).length; - const passing = checks.filter(c => c.data.state === 'success').length; - const pendingCount = checks.filter(c => c.data.state === 'pending').length; - const pending = pendingCount > 0; - const summary = failing - ? `CI: ${failing} of ${checks.length} failing` - : pending - ? `CI running · ${passing} of ${checks.length} passed` - : `CI passed · ${n(checks.length, 'check')}`; - return ( - ( - - )} - > - - CI checks - - · {passing} of {checks.length} passed - - - {checks.map(c => { - const meta = CI_STATE_META[c.data.state]; - const dur = ciDuration(c); - const inner = ( - <> - - - - {c.data.context} - {meta.word} - {dur && ( - - {dur} - - )} - - ); - return c.data.target_url ? ( - - {inner} - - ) : ( - - {inner} - - ); - })} - - ); -} - -/** - * Sign-off state as a circle-check meter: one mark per required stamp, in a - * fixed-width slot so CR, QA, CI, and the weight letter land at the same x - * down a board. One mark, three standings (see styles.css .pip): a solid disc - * = an approval that stands, the same mark drained to an outline = it stood - * once but a push lapsed it (the most actionable state on the board), an - * empty ring = still needed, a muted dash = nothing required. A dotted - * underline marks a slot you personally stamped. No enclosing chip at all: - * the marks are confident enough to stand bare beside their label — every - * box, wash, and hairline this slot has worn turned out to be scaffolding - * (the earlier colored squares and tinted backgrounds were a private code - * that sent eyes to the rail instead of the titles). - * - * `label` only feeds the aria/title strings ("CR 2 of 2…") — it renders - * nothing here. The visible "CR"/"QA" glyph is the caller's (Row.tsx), the - * same fixed-width slot the CI label and weight letter use, so a caller can - * slot something else (the weight letter) between the label and these marks - * without it ending up inside this component's own hover/click surface. - */ -export function Pips({ - label, - have, - req, - by = [], - staleBy = [], - me, - titled = true, -}: { - label: string; - have: number; - req: number; - /** users with a live stamp */ - by?: string[]; - /** users whose stamp a push invalidated */ - staleBy?: string[]; - me?: string; - /** false when a hover popover wraps these pips (SigPips): the popover - * carries the same facts, and a native title would stack the browser - * tooltip under it — the same double-tooltip AgeStamp solved by switching - * to aria-label. The bare-pips fallback keeps its title. */ - titled?: boolean; -}) { - const none = !req && !have && !staleBy.length; - const met = !none && have >= req; - const mine = me != null && by.includes(me); - const owedByMe = me != null && staleBy.includes(me); - const on = Math.min(have, req); - const stale = Math.max(0, Math.min(staleBy.length, req - on)); - const off = Math.max(0, req - on - stale); - const aria = none - ? `${label} not required` - : `${label} ${have} of ${req}` + - (met ? ', done' : '') + - (mine ? ', including yours' : '') + - (staleBy.length - ? owedByMe - ? ', your stamp was invalidated by a push' - : `, ${staleBy.join(', ')}'s stamp was invalidated by a push` - : ''); - const title = none - ? `${label} not required` - : staleBy.length - ? `${staleBy.join(', ')} stamped an earlier version; a push invalidated it` - : met - ? mine - ? `${label} done, including your stamp` - : `${label} done` - : `${label}: ${have} of ${req}`; - // No chip-level tint at all: a filled wash — however muted — out-competed - // the section headers and titles the eye should scan first (salience is - // about form, not volume). The re-stamp-owed signal lives on the stale pip - // itself instead: a thin amber halo confined to its own 8px square (see - // .pip-stale). The word-group header and the pip-mine underline already - // say whose move it is; the hover title spells out who and when. - return ( - - {none ? ( - - – - - ) : ( - // min-width sized to the 3-mark case, not the common two: the - // rail is right-anchored, so a slot that hugged two marks made a - // 3-required pull grow the rail leftward and knock the age - // numeral off its right-edge column in the meta line. The air - // this reserves before the next label matches what the CI slot - // already reserves for its failing cluster — one rail width, - // every row. A 4-required pull (unseen in practice) still grows. - - {/* keyed by fixed slot, not by fill kind, so a pip that flips - off→on (stamp lands) or on→stale (a push invalidates it) is - the same node easing its color, not a fresh mount */} - {[ - ...Array.from({ length: on }, () => 'on'), - ...Array.from({ length: stale }, () => 'stale'), - ...Array.from({ length: off }, () => 'off'), - ].map((kind, i) => ( - - ))} - - )} - - ); -} - -/** - * The ledger slot as a drill-down: click to see who signed, who went stale, - * and when — the answer v1 kept in per-signer bubbles and v2's counts lost. - * Falls back to the plain ledger when there's nothing to list. - */ -export function SigPips({ - label, - have, - req, - by = [], - staleBy = [], - me, - sigs, - lead, - panelExtra, -}: { - label: string; - have: number; - req: number; - by?: string[]; - staleBy?: string[]; - me?: string; - sigs: Signature[]; - /** rendered inside the trigger before the pips — the cluster's text label - * (and, for CR, the weight letter), so the words are part of the door */ - lead?: ReactNode; - /** an extra panel section under the signer rows (CR carries the weight - * drill-down here — one popover for the whole cluster) */ - panelExtra?: ReactNode; -}) { - const pips = ( - - ); - - // latest signature per user, live stamps first, then invalidated ones - const latest = new Map(); - for (const s of sigs) { - const prev = latest.get(s.data.user.login); - if (!prev || s.data.created_at > prev.data.created_at) latest.set(s.data.user.login, s); - } - const rows = [...latest.values()].sort( - (a, b) => - Number(b.data.active) - Number(a.data.active) || - (a.data.created_at < b.data.created_at ? 1 : -1) - ); - - return ( - ( - - )} - > - {/* restate the fraction: the trigger's hover title is mouse-only, - and a tap-opened panel must carry the "how many still needed" */} - - {label} stamps - {req > 0 && ( - - · {have} of {req} - - )} - - {rows.length === 0 && ( - No stamps yet. - )} - {rows.map(s => ( - - - {s.data.user.login} - {s.data.user.login === me && (you)} - - {ago(epoch(s.data.created_at))} ago - - {s.data.active ? ( - - ) : ( - - )} - - ))} - {/* the mark vocabulary, keyed right where the marks are read — the - legend stays a conventions card, not a per-mark decoder */} - - stands - staled by a push - needed - - {panelExtra} - - ); -} - -/** - * The age popover's body: "opened X ago", "last activity Y ago", and (only - * when the row's age line is actually drawn) the line's own relative-to- - * the-oldest explanation. Shared by AgeStamp (the numeral, every row) and - * AgeBaseline (the hoverable band, only aging rows) so the two doors into - * the same fact can never drift apart — one copy, two triggers. - */ -function AgePopoverBody({ - createdAt, - updatedAt, - explainLine, -}: { - /** epoch secs the pull opened */ - createdAt: number; - /** epoch secs of the last activity */ - updatedAt: number; - /** the caller decides: the numeral shows on every row and only adds this - * once its own age crosses warnDays, the band only ever renders once - * that's already true, so it always passes it. */ - explainLine: boolean; -}) { - return ( - <> - - opened {ago(createdAt)} ago - - - last activity {ago(updatedAt)} ago - - {explainLine && ( - - the grey line under this row is its age, relative to the board’s oldest open pull - - )} - - ); -} - -/** - * Age as the row's own baseline: a 1px hairline along the bottom edge — a - * tinted stretch of the divider the row already has, not a drawn bar. - * RELATIVE, not thresholded: the board's longest-open pull sets the full - * track and every row is a fraction of it, so the line answers "how long - * has this waited, relative to what waiting looks like here." The tint - * deepens with that same fraction, ultra-light ink for the merely-aging - * through full ink-3 for the oldest — age is a quiet fact in grey, never - * a colored alarm (an amber version shipped for an hour and was the - * loudest thing on the board; urgency belongs to the queue's ranking and - * the numeral's weight). Silent below the aging threshold — a healthy - * young row draws nothing. - * - * The line is also a door now, not pure geometry: a 10px invisible hit strip - * (`.pd-age-track`/`.pd-age-hit` in styles.css), sized to the SAME fraction - * as the visible line so a 1px target doesn't need pixel-hunting, opens the - * same age popover the numeral shows on hover or focus and grows the line to - * an 8px band while it's open. At rest it's pixel-identical to the old - * static hairline. - */ -export function AgeBaseline({ - ageDays, - createdAt, - updatedAt, - warnDays = STARVE_DAYS, - maxAgeDays = 1, - quiet, -}: { - ageDays: number; - /** epoch secs the pull opened — feeds the shared age popover body */ - createdAt: number; - /** epoch secs of the last activity — feeds the shared age popover body */ - updatedAt: number; - warnDays?: number; - /** the board's longest-open pull — the 100% mark of the track */ - maxAgeDays?: number; - /** drafts and holds age on purpose: draw nothing */ - quiet?: boolean; -}) { - if (quiet || ageDays < warnDays) return null; - const t = Math.min(ageDays / Math.max(maxAgeDays, 1), 1); - // tint rides the same fraction as length: ~30% border-grey at the - // gate, the full border color on the board's oldest (owner call: the - // ink ramp read too dark against the row divider it extends) - const inkPct = Math.round(30 + 70 * t); - return ( - // the percentage width lives on this outer track (not the line itself, - // see styles.css): the line and its taller hit area both fill 100% of - // it, so hovering anywhere along the row's actual age fraction — never - // past it — opens the door. - - ( - - )} - > - - - - ); -} - -/** - * The age slot: hours under a day, then days, with both clocks in the - * popover. Hours matter here: in three months of real history, 62% of - * pulls merged same-day, so "0d" was a dead signal for most of the live - * board. It rides at the rail's far right, capping the row — and it - * WHISPERS: its resting color is the age line's own border tint, rising to - * ink on row hover (see styles.css .pd-age-num). Age's salience is carried - * once, by the baseline built for continuous gradation; the numeral is the - * label you consult, not a second alarm. Font weight still steps at the - * warn/rot thresholds so the hover read carries the urgency. - * - * `display` picks the clock the numeral shows (opened vs last update); the - * urgency weight always follows the OPENED clock — how long a pull has been - * open is the truth the board ranks by, whichever number the user prefers - * to read. - */ -export function AgeStamp({ - ageDays, - createdAt, - updatedAt, - quiet, - warnDays = STARVE_DAYS, - rotDays = ROT_DAYS, - inline, - display = 'opened', -}: { - ageDays: number; - /** epoch secs the pull opened */ - createdAt: number; - /** epoch secs of the last activity */ - updatedAt: number; - /** drafts and holds age on purpose: no urgency weight */ - quiet?: boolean; - /** heavier type at/after this many days (user setting; defaults to the model's) */ - warnDays?: number; - /** heaviest type at/after this many days */ - rotDays?: number; - /** true when it's embedded in a flowing meta line rather than the rail's - * fixed-width column — drops the w-7/text-right slot in favor of plain - * inline text. */ - inline?: boolean; - /** which clock the numeral shows (settings.ageDisplay) */ - display?: 'opened' | 'updated'; -}) { - const heft = quiet - ? '' - : ageDays >= rotDays - ? 'font-semibold' - : ageDays >= warnDays - ? 'font-medium' - : ''; - const shownEpoch = display === 'updated' ? updatedAt : createdAt; - const shownDays = - display === 'updated' - ? Math.max(0, Math.floor((Date.now() / 1000 - updatedAt) / 86400)) - : ageDays; - const text = shownDays === 0 ? ago(shownEpoch) : `${shownDays}d`; - // a popover, not a title: the second clock (last activity) exists nowhere - // else on the row, and a native tooltip is mouse-only — this way touch - // taps it and keyboard reads it from the aria-label - return ( - ( - - )} - > - = warnDays} - /> - - ); -} - /** * The full-title GitHub link every row variant renders — never truncated. * With `stretch`, its click target covers the whole positioned row (see diff --git a/frontend-v2/src/components/filters/PeopleFilter.tsx b/frontend-v2/src/components/filters/PeopleFilter.tsx index 9941ff9c..82a2e73f 100644 --- a/frontend-v2/src/components/filters/PeopleFilter.tsx +++ b/frontend-v2/src/components/filters/PeopleFilter.tsx @@ -4,7 +4,8 @@ import { displayName, useNames } from '../../model/names'; import type { Scope } from '../../prefs'; import { toggleHiddenPerson, toggleTeammate, useSettings } from '../../settings'; import { usePulldasher } from '../../store'; -import { Avatar, QuietButton, StarMark } from '../bits'; +import { QuietButton } from '../bits'; +import { Avatar, StarMark } from '../identity'; import { Popover } from '../Popover'; import { ClearRow, diff --git a/frontend-v2/src/components/filters/RepoFilter.tsx b/frontend-v2/src/components/filters/RepoFilter.tsx index 75b108a6..c5d74fe8 100644 --- a/frontend-v2/src/components/filters/RepoFilter.tsx +++ b/frontend-v2/src/components/filters/RepoFilter.tsx @@ -3,7 +3,8 @@ import { repoState } from '../../model/visibility'; import { shortRepo } from '../../format'; import type { Scope } from '../../prefs'; import { setRepoPref, togglePrimaryRepo, useSettings } from '../../settings'; -import { QuietButton, StarMark } from '../bits'; +import { QuietButton } from '../bits'; +import { StarMark } from '../identity'; import { Popover } from '../Popover'; import { ClearRow, FilterSearch, FilterTrigger, OnlyButton } from './shared'; diff --git a/frontend-v2/src/components/identity.tsx b/frontend-v2/src/components/identity.tsx new file mode 100644 index 00000000..c0a40094 --- /dev/null +++ b/frontend-v2/src/components/identity.tsx @@ -0,0 +1,208 @@ +import { useEffect, useState } from 'react'; +import type { CSSProperties } from 'react'; +import { Star } from 'lucide-react'; +import { githubAvatarUrl, githubProfileUrl, loginHue } from '../format'; +import { displayName, requestNames, useNames } from '../model/names'; +import { Icon } from './Icon'; +import { Popover } from './Popover'; + +/** + * The board's author-identity system — the silhouette and the star: + * + * - SHAPE answers "person or machine": people are circles, bots (GitHub + * Apps and config-listed machine accounts) are rounded squares, the + * app-tile idiom Slack and GitHub already taught. No glyph, no hue — the + * outline is the mark, so it reads at 16px and in peripheral vision. + * - The BOTTOM-RIGHT CORNER answers "what is this person to you", in one + * glyph: a small star on a teammate (your review circle), and a larger star seated on + * the rim of your own avatar with a bite masked out of the face, so your + * silhouette is visibly broken. You are the star vocabulary's largest + * case — escalated by size and form, never by fill. (The ringed seal and + * the v1 star-coin both died here: a halo reads as focus, a coin loses + * the face.) + * - Identity, never state: none of it animates or changes with PR status. + */ + +/** The you-mark's geometry, shared by the face's bite mask and the star + * seated in it: star size, the star's center relative to the avatar box, + * and the moat (bite) radius. One source so mask and glyph can't drift. */ +function youStarGeometry(size: number) { + const star = size >= 20 ? 13 : 11; + const offset = size >= 20 ? 3 : 2.5; + const center = size + offset - star / 2; + return { star, offset, center, moat: star / 2 + 2 }; +} + +/** + * The face itself: the GitHub picture over the deterministic-hue initials. + * The initials sit underneath and show through the instant the image 404s + * (bots, deleted accounts, an offline CDN) — so we always render something, + * never a broken-image glyph. + */ +function AvatarFace({ + login, + size, + shape = 'circle', + bitten = false, +}: { + login: string; + size: number; + /** 'square' = a bot/app tile (~18% corner radius); 'circle' = a person */ + shape?: 'circle' | 'square'; + /** notch the bottom-right rim out of the face so the you-star can seat + * IN the silhouette rather than merely on top of it */ + bitten?: boolean; +}) { + const [broken, setBroken] = useState(false); + // OKLCH holds perceived lightness constant across the hue wheel — the old + // hsl(h 45% 45%) made yellow-green logins illegible under white text + const style: CSSProperties = { + background: `oklch(0.48 0.09 ${loginHue(login)})`, + width: size, + height: size, + fontSize: Math.round(size * 0.42), + borderRadius: shape === 'square' ? Math.max(3, Math.round(size * 0.18)) : 9999, + }; + if (bitten) { + const g = youStarGeometry(size); + // transparent moat, not a painted stroke: the bite stays correct over + // any row background (hover, fresh-flash), which the old seal's + // surface-colored outline never quite did + const mask = `radial-gradient(circle at ${g.center}px ${g.center}px, transparent ${g.moat - 0.25}px, #000 ${g.moat + 0.25}px)`; + style.WebkitMaskImage = mask; + style.maskImage = mask; + } + return ( + + {login.slice(0, 2)} + {!broken && ( + setBroken(true)} + /> + )} + + ); +} + +/** The hover card behind a clickable avatar: the picture bigger, the human + * name when GitHub has one (resolved through model/names.ts's cached + * server-side lookup), the handle, and a jump to their GitHub profile. */ +function PersonCard({ login }: { login: string }) { + const names = useNames(); + useEffect(() => requestNames([login]), [login]); + const name = displayName(names, login); + return ( + + ); +} + +export function Avatar({ + login, + size = 22, + onClick, + shape = 'circle', + you = false, +}: { + login: string; + size?: number; + onClick?: (login: string) => void; + /** 'square' for bot/app authors — see the identity-system note above */ + shape?: 'circle' | 'square'; + /** the viewer's own avatar: wears the seated corner star + bitten rim */ + you?: boolean; +}) { + const g = you ? youStarGeometry(size) : null; + // the star rides INSIDE whatever scales on hover, so the bite in the face + // and the star seated in it can never fall out of registration + const face = ( + + + {g && ( + + + + )} + + ); + if (!onClick) { + return ( + + {face} + + ); + } + // A clickable avatar keeps its one-click "filter to this person" gesture + // (the trigger's own onClick), and grows a hover preview card on top — the + // same hover-open/pin discipline AgeStamp and the ledger use. Hover is a + // supplement (the picture bigger + a GitHub link); the click action stays + // fully keyboard- and touch-reachable. + return ( + ( + + )} + > + + + ); +} + +/** + * The one star mark every "primary repo" / "teammate" toggle shares + * (Row's kebab menu, RepoFilter, PeopleFilter, RepoManager): filled when on, + * outline when off, same lucide glyph everywhere instead of five hand-rolled + * ★/☆ copies. + */ +export function StarMark({ on, size = 14 }: { on: boolean; size?: number }) { + return ; +} diff --git a/frontend-v2/src/components/pips.tsx b/frontend-v2/src/components/pips.tsx new file mode 100644 index 00000000..495c752c --- /dev/null +++ b/frontend-v2/src/components/pips.tsx @@ -0,0 +1,397 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Check, CircleDot, X } from 'lucide-react'; +import { type DerivedPull, headStatuses } from '../model/status'; +import type { CommitStatus, Signature } from '../types'; +import { ago, epoch, n, signatureUrl } from '../format'; +import { Avatar } from './identity'; +import { Icon } from './Icon'; +import { Popover } from './Popover'; + +const CI_STATE_META: Record< + CommitStatus['data']['state'], + { icon: typeof Check; color: string; word: string } +> = { + success: { icon: Check, color: 'var(--ok)', word: 'passed' }, + failure: { icon: X, color: 'var(--bad)', word: 'failed' }, + error: { icon: X, color: 'var(--bad)', word: 'errored' }, + pending: { icon: CircleDot, color: 'var(--slate)', word: 'running' }, +}; + +const isRedCheck = (s: CommitStatus) => s.data.state === 'failure' || s.data.state === 'error'; + +// failures first, then the still-running ones, then the greens; alphabetical +// within a tier so the list is stable run to run +function ciRank(s: CommitStatus): number { + return isRedCheck(s) ? 0 : s.data.state === 'pending' ? 1 : 2; +} + +/** completed − started, when both timestamps are known, as a terse "45s"/"6m". */ +function ciDuration(s: CommitStatus): string | null { + const { started_at, completed_at } = s.data; + if (started_at == null || completed_at == null) return null; + const secs = Math.max(0, completed_at - started_at); + return secs < 60 ? `${Math.round(secs)}s` : `${Math.round(secs / 60)}m`; +} + +/** + * CI in the sign-off family: the machine is a reviewer, so it wears the same + * label + circle-mark anatomy as CR and QA. Only a failure earns ink at rest + * (a red X disc plus the failing count); a passing or still-running check + * renders in a reserved, invisible slot and is revealed on row hover — the + * same "green only on hover" physics the old segmented bar had, now in one + * vocabulary. The trigger opens the per-check list: state, name, duration, + * and a link for each. A fixed-width placeholder (not null) stands in for a + * pull with no checks, so the rail's other slots don't shift row-to-row. + */ +export function CiStatus({ pull }: { pull: DerivedPull }) { + const checks = + pull.ci === 'none' + ? [] + : [...headStatuses(pull.data)].sort( + (a, b) => ciRank(a) - ciRank(b) || a.data.context.localeCompare(b.data.context) + ); + if (!checks.length) return ; + + const failing = checks.filter(isRedCheck).length; + const passing = checks.filter(c => c.data.state === 'success').length; + const pendingCount = checks.filter(c => c.data.state === 'pending').length; + const pending = pendingCount > 0; + const summary = failing + ? `CI: ${failing} of ${checks.length} failing` + : pending + ? `CI running · ${passing} of ${checks.length} passed` + : `CI passed · ${n(checks.length, 'check')}`; + return ( + ( + + )} + > + + CI checks + + · {passing} of {checks.length} passed + + + {checks.map(c => { + const meta = CI_STATE_META[c.data.state]; + const dur = ciDuration(c); + const inner = ( + <> + + + + {c.data.context} + {meta.word} + {dur && ( + + {dur} + + )} + + ); + return c.data.target_url ? ( + + {inner} + + ) : ( + + {inner} + + ); + })} + + ); +} + +/** + * Sign-off state as a circle-check meter: one mark per required stamp, in a + * fixed-width slot so CR, QA, CI, and the weight letter land at the same x + * down a board. One mark, three standings (see styles.css .pip): a solid disc + * = an approval that stands, the same mark drained to an outline = it stood + * once but a push lapsed it (the most actionable state on the board), an + * empty ring = still needed, a muted dash = nothing required. A dotted + * underline marks a slot you personally stamped. No enclosing chip at all: + * the marks are confident enough to stand bare beside their label — every + * box, wash, and hairline this slot has worn turned out to be scaffolding + * (the earlier colored squares and tinted backgrounds were a private code + * that sent eyes to the rail instead of the titles). + * + * `label` only feeds the aria/title strings ("CR 2 of 2…") — it renders + * nothing here. The visible "CR"/"QA" glyph is the caller's (Row.tsx), the + * same fixed-width slot the CI label and weight letter use, so a caller can + * slot something else (the weight letter) between the label and these marks + * without it ending up inside this component's own hover/click surface. + */ +export function Pips({ + label, + have, + req, + by = [], + staleBy = [], + me, + titled = true, +}: { + label: string; + have: number; + req: number; + /** users with a live stamp */ + by?: string[]; + /** users whose stamp a push invalidated */ + staleBy?: string[]; + me?: string; + /** false when a hover popover wraps these pips (SigPips): the popover + * carries the same facts, and a native title would stack the browser + * tooltip under it — the same double-tooltip AgeStamp solved by switching + * to aria-label. The bare-pips fallback keeps its title. */ + titled?: boolean; +}) { + const none = !req && !have && !staleBy.length; + const met = !none && have >= req; + const mine = me != null && by.includes(me); + const owedByMe = me != null && staleBy.includes(me); + const on = Math.min(have, req); + const stale = Math.max(0, Math.min(staleBy.length, req - on)); + const off = Math.max(0, req - on - stale); + const aria = none + ? `${label} not required` + : `${label} ${have} of ${req}` + + (met ? ', done' : '') + + (mine ? ', including yours' : '') + + (staleBy.length + ? owedByMe + ? ', your stamp was invalidated by a push' + : `, ${staleBy.join(', ')}'s stamp was invalidated by a push` + : ''); + const title = none + ? `${label} not required` + : staleBy.length + ? `${staleBy.join(', ')} stamped an earlier version; a push invalidated it` + : met + ? mine + ? `${label} done, including your stamp` + : `${label} done` + : `${label}: ${have} of ${req}`; + // No chip-level tint at all: a filled wash — however muted — out-competed + // the section headers and titles the eye should scan first (salience is + // about form, not volume). The re-stamp-owed signal lives on the stale pip + // itself instead: a thin amber halo confined to its own 8px square (see + // .pip-stale). The word-group header and the pip-mine underline already + // say whose move it is; the hover title spells out who and when. + return ( + + {none ? ( + + – + + ) : ( + // min-width sized to the 3-mark case, not the common two: the + // rail is right-anchored, so a slot that hugged two marks made a + // 3-required pull grow the rail leftward and knock the age + // numeral off its right-edge column in the meta line. The air + // this reserves before the next label matches what the CI slot + // already reserves for its failing cluster — one rail width, + // every row. A 4-required pull (unseen in practice) still grows. + + {/* keyed by fixed slot, not by fill kind, so a pip that flips + off→on (stamp lands) or on→stale (a push invalidates it) is + the same node easing its color, not a fresh mount */} + {[ + ...Array.from({ length: on }, () => 'on'), + ...Array.from({ length: stale }, () => 'stale'), + ...Array.from({ length: off }, () => 'off'), + ].map((kind, i) => ( + + ))} + + )} + + ); +} + +/** + * The ledger slot as a drill-down: click to see who signed, who went stale, + * and when — the answer v1 kept in per-signer bubbles and v2's counts lost. + * Falls back to the plain ledger when there's nothing to list. + */ +export function SigPips({ + label, + have, + req, + by = [], + staleBy = [], + me, + sigs, + lead, + panelExtra, +}: { + label: string; + have: number; + req: number; + by?: string[]; + staleBy?: string[]; + me?: string; + sigs: Signature[]; + /** rendered inside the trigger before the pips — the cluster's text label + * (and, for CR, the weight letter), so the words are part of the door */ + lead?: ReactNode; + /** an extra panel section under the signer rows (CR carries the weight + * drill-down here — one popover for the whole cluster) */ + panelExtra?: ReactNode; +}) { + const pips = ( + + ); + + // latest signature per user, live stamps first, then invalidated ones + const latest = new Map(); + for (const s of sigs) { + const prev = latest.get(s.data.user.login); + if (!prev || s.data.created_at > prev.data.created_at) latest.set(s.data.user.login, s); + } + const rows = [...latest.values()].sort( + (a, b) => + Number(b.data.active) - Number(a.data.active) || + (a.data.created_at < b.data.created_at ? 1 : -1) + ); + + return ( + ( + + )} + > + {/* restate the fraction: the trigger's hover title is mouse-only, + and a tap-opened panel must carry the "how many still needed" */} + + {label} stamps + {req > 0 && ( + + · {have} of {req} + + )} + + {rows.length === 0 && ( + No stamps yet. + )} + {rows.map(s => ( + + + {s.data.user.login} + {s.data.user.login === me && (you)} + + {ago(epoch(s.data.created_at))} ago + + {s.data.active ? ( + + ) : ( + + )} + + ))} + {/* the mark vocabulary, keyed right where the marks are read — the + legend stays a conventions card, not a per-mark decoder */} + + stands + staled by a push + needed + + {panelExtra} + + ); +} diff --git a/frontend-v2/src/views/Team.tsx b/frontend-v2/src/views/Team.tsx index 674d1748..affd892f 100644 --- a/frontend-v2/src/views/Team.tsx +++ b/frontend-v2/src/views/Team.tsx @@ -7,8 +7,9 @@ import { matchesRegion } from '../model/regions'; import { crSort } from '../model/sort'; import { teamBuckets } from '../model/team'; import { addTeam, DEFAULT_TEAM_NAME, myPeople, removeTeam, useSettings } from '../settings'; -import { Avatar, EmptyState, QuietButton } from '../components/bits'; +import { EmptyState, QuietButton } from '../components/bits'; import { Icon } from '../components/Icon'; +import { Avatar } from '../components/identity'; import { Fold, FoldRows, Lane, laneShown, RestGroup, SubDoor } from '../components/Lane'; import { Popover } from '../components/Popover'; import type { RowOptions } from '../components/Row'; diff --git a/frontend-v2/src/views/stats/parts.tsx b/frontend-v2/src/views/stats/parts.tsx index 9f3a8aa4..de35971c 100644 --- a/frontend-v2/src/views/stats/parts.tsx +++ b/frontend-v2/src/views/stats/parts.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { Avatar } from '../../components/bits'; +import { Avatar } from '../../components/identity'; import type { Weight } from '../../model/status'; /** From 0cb760551dd98c072508efa28cfa87e7d3043344 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 16:39:39 -0700 Subject: [PATCH 321/381] Bound the saved-filter gloss so truncate can bite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A roster search's gloss lists its members, and a three-login roster ran straight out of the panel's right edge, under the pin control. The row button is a flex-col with items-start, so its children shrink-to-fit: without an explicit width bound the gloss span grows to its content and `truncate`'s overflow-hidden has nothing to clip against. max-w-full on both row lines gives the ellipsis something to bind to. (Found on the owner's real board — the dummy rosters were too short to ever overflow, which is also why a scrollWidth probe missed it: an unconstrained span's clientWidth grows with the content.) Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/SavedFiltersPanel.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend-v2/src/components/SavedFiltersPanel.tsx b/frontend-v2/src/components/SavedFiltersPanel.tsx index 4aca8aa4..d36e0180 100644 --- a/frontend-v2/src/components/SavedFiltersPanel.tsx +++ b/frontend-v2/src/components/SavedFiltersPanel.tsx @@ -76,7 +76,10 @@ function SavedFilterRow({ onClick={onApply} className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left" > - + {/* max-w-full on both lines: in a flex-col with items-start the + children shrink-to-fit, so without a width bound `truncate` + never binds and a long roster gloss runs out of the panel */} + {filter.name} {current && ( @@ -84,7 +87,7 @@ function SavedFilterRow({ )} - + {filter.auto ? `your roster · ${describeHash(filter.hash)}` : describeHash(filter.hash)} From cce0d579feaf48df3b924291070b038b5f830e72 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 16:51:06 -0700 Subject: [PATCH 322/381] Failing CI no longer hides a pull from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Danny's report: PRs with red CI were kept out of the review queue, which we've never done — CI false-negatives are common enough that whether to review a failing build is the reviewer's call, and authors shouldn't babysit re-runs to get review attention. Root cause was the status chain: ci_red outranked needs_cr/needs_recr, so a failing build could never read as "needs review" and every reviewable pool (queue, deal, team buckets, cheers backlog, re-stamp obligations) skipped it by construction. CI now gates readiness only, exactly like ci_pending always has: ci_red means FULLY SIGNED OFF with a red build — the author's last remaining move — and a red build mid-review keeps its needs_cr status, flows through the queues, and shows its state on the rail's CI pip. The author's side keeps its full breadth via the ci FLAG rather than the status: Fix CI stays their verb, and the CI-failed nudge/desktop alert still fires the moment a build goes red, stamped or not. The Review lens's "CI failing" fold now holds only the signed-off-but-red pulls, and its gloss says so. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/model/actions.ts | 24 +++++++++++++++--------- frontend-v2/src/model/cheers.ts | 4 +++- frontend-v2/src/model/status.test.ts | 23 ++++++++++++++++++----- frontend-v2/src/model/status.ts | 19 +++++++++++++------ frontend-v2/src/views/Review.tsx | 16 ++++++++-------- 5 files changed, 57 insertions(+), 29 deletions(-) diff --git a/frontend-v2/src/model/actions.ts b/frontend-v2/src/model/actions.ts index cdee40bf..05563800 100644 --- a/frontend-v2/src/model/actions.ts +++ b/frontend-v2/src/model/actions.ts @@ -6,13 +6,15 @@ import { isBotLogin } from './visibility'; /** * The three statuses that put the ball wholly with the author — while one * holds, the board asks reviewers for NOTHING, not even an owed re-stamp: a - * draft isn't reviewable, a dev block means more pushes are coming, and red - * CI means the head everyone would re-review is about to change. Gates the - * personal-obligation checks (Re-stamp / Finish QA / Re-QA) in reviewerMove, - * reviewerNote, and the `is:restamp` query token, so a stale stamp only asks - * for its re-stamp once the pull is reviewable again. (QA obligations stay - * un-gated across the OTHER statuses on purpose — QA runs in parallel with - * CR on this board.) + * draft isn't reviewable, a dev block means more pushes are coming, and + * ci_red only exists once a pull is fully signed off (model/status moved CI + * to the ready gate), so nothing is left to ask of reviewers but the build. + * A red build on a pull still AWAITING review keeps its needs_cr/needs_recr + * status and stays in the queues — whether to review a failing build is the + * reviewer's call, not the board's. Gates the personal-obligation checks + * (Re-stamp / Finish QA / Re-QA) in reviewerMove, reviewerNote, and the + * `is:restamp` query token. (QA obligations stay un-gated across the OTHER + * statuses on purpose — QA runs in parallel with CR on this board.) */ export const authorOwnsIt = (p: DerivedPull): boolean => p.status === 'draft' || p.status === 'dev_block' || p.status === 'ci_red'; @@ -37,7 +39,10 @@ export const parked = (p: DerivedPull): boolean => p.cryo; export function authorMove(p: DerivedPull): string | null { if (parked(p)) return null; if (p.status === 'ready') return 'Merge it'; - if (p.status === 'ci_red') return 'Fix CI'; + // the ci FLAG, not just the ci_red status: a red build mid-review keeps + // its needs_cr status (reviewers may still take it), but fixing the build + // is the author's move either way + if (p.status === 'ci_red' || p.ci === 'failing') return 'Fix CI'; // a dev block is feedback waiting on YOU — v1 lore said "ask them to lift // it", which misroutes the most common author action. But a block you put // on your own PR (self-flagged "hold off") isn't feedback to answer — the @@ -137,7 +142,8 @@ function authorNote(p: DerivedPull, me: string): RowNote { const crReq = d.status.cr_req; if (p.status === 'draft') return doOnly('Undraft'); - if (p.status === 'ci_red') + // flag, not just status: red CI mid-review still names the author's move + if (p.status === 'ci_red' || p.ci === 'failing') return { action: 'Fix CI', context: p.ciFailing.length ? p.ciFailing.join(', ') : null }; // a dev block is feedback waiting on YOU — v1 lore said "ask them to lift // it", which misroutes the most common author action. But when you're the diff --git a/frontend-v2/src/model/cheers.ts b/frontend-v2/src/model/cheers.ts index dc1d93c9..d74cd0ac 100644 --- a/frontend-v2/src/model/cheers.ts +++ b/frontend-v2/src/model/cheers.ts @@ -417,7 +417,9 @@ export function readSignals(input: CheerInput): Signals { reviewed: p.crBy.some(l => l !== me), conflict: p.conflict, starved: p.starved, - ciRed: p.status === 'ci_red', + // the flag, not the status: ci_red now only exists once signed off, + // but the author's "CI failed" nudge should fire mid-review too + ciRed: p.ci === 'failing', needsAnswer: p.status === 'dev_block' || p.changesRequestedBy.length > 0, }); } diff --git a/frontend-v2/src/model/status.test.ts b/frontend-v2/src/model/status.test.ts index a57419a5..ea970636 100644 --- a/frontend-v2/src/model/status.test.ts +++ b/frontend-v2/src/model/status.test.ts @@ -89,12 +89,14 @@ describe('status derivation precedence', () => { expect(derive(p, undefined, NOW).status).toBe('dev_block'); }); - it('a deploy block gates only the late stages, never review or CI', () => { + it('a deploy block gates only the late stages, never review', () => { + // unreviewed: a hold (and even a red build) never pulls it out of the + // review lanes — reviewers decide what to pick up const red = withStatus({ deploy_block: [sig('deploy_block', 'x', true)], commit_statuses: [ci('failure')], }); - expect(derive(red, undefined, NOW).status).toBe('ci_red'); + expect(derive(red, undefined, NOW).status).toBe('needs_cr'); const held = withStatus({ deploy_block: [sig('deploy_block', 'ops', true)], @@ -106,9 +108,20 @@ describe('status derivation precedence', () => { expect(d.deployBlockedBy).toEqual(['ops']); }); - it('failing CI beats the review lanes', () => { - const p = withStatus({ commit_statuses: [ci('failure')] }); - expect(derive(p, undefined, NOW).status).toBe('ci_red'); + it('failing CI never leaves the review lanes; ci_red means signed off + red', () => { + // unreviewed with a red build: reviewing it is the reviewer's call, so + // the status stays needs_cr — the ci flag carries red to the rail's pip + const unreviewed = derive(withStatus({ commit_statuses: [ci('failure')] }), undefined, NOW); + expect(unreviewed.status).toBe('needs_cr'); + expect(unreviewed.ci).toBe('failing'); + + // fully signed off: CI is the only gate left, so the status says so + const signedOff = withStatus({ + allCR: [sig('CR', 'r', true)], + allQA: [sig('QA', 'q', true)], + commit_statuses: [ci('failure')], + }); + expect(derive(signedOff, undefined, NOW).status).toBe('ci_red'); }); it('an invalidated CR makes needs_recr, and names who owes the re-stamp', () => { diff --git a/frontend-v2/src/model/status.ts b/frontend-v2/src/model/status.ts index 89e3b7b6..a121934e 100644 --- a/frontend-v2/src/model/status.ts +++ b/frontend-v2/src/model/status.ts @@ -6,8 +6,8 @@ import { isBotLogin } from './visibility'; * One pull, one status. Mutually exclusive by precedence — the fix for v1's * six overlapping column predicates (a pull could sit in CR and QA at once). * - * draft > dev_block > ci_red > needs_recr > needs_cr > needs_qa > - * deploy_block > ci_pending > unmergeable > ready + * draft > dev_block > needs_recr > needs_cr > needs_qa > + * ci_red > deploy_block > ci_pending > unmergeable > ready * * The old single "blocked" bucket conflated three opposite situations, so it * split: dev_block means the author owes changes (it hides the pull from the @@ -16,9 +16,13 @@ import { isBotLogin } from './visibility'; * outranks the ready gate); unmergeable means signed off and green but * conflicted or based on an unmerged parent (the author rebases). * - * ci_pending exists only at the ready gate: pending CI never hides a pull - * from the review lanes (reviews don't need green), but a fully-signed-off - * pull isn't "ready" until CI agrees. + * BOTH CI states exist only at the ready gate: a red or pending build never + * hides a pull from the review lanes — reviews don't need green, CI + * false-negatives are common, and whether to review a failing build is the + * reviewer's call (the rail's CI pip shows the state either way; authors + * shouldn't have to babysit re-runs to get review attention). A + * fully-signed-off pull isn't "ready" until CI agrees: ci_red means the + * author fixes the build, ci_pending means everyone waits a minute. */ export type Status = | 'draft' @@ -275,10 +279,13 @@ export function derive( let status: Status; if (pull.draft) status = 'draft'; else if (devBlockedBy.length) status = 'dev_block'; - else if (ci === 'failing') status = 'ci_red'; else if (!crMet && staleCr.length) status = 'needs_recr'; else if (!crMet) status = 'needs_cr'; else if (!qaMet) status = 'needs_qa'; + // CI gates readiness only (see the Status doc): a red or pending build + // never pulls a PR out of the review lanes — the `ci` field carries the + // state to the rail's pip whatever the status says + else if (ci === 'failing') status = 'ci_red'; else if (deployBlockedBy.length) status = 'deploy_block'; else if (ci === 'pending') status = 'ci_pending'; // signed off and green, but unmergeable as-is: the author rebases diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index c7bf4bfb..5749912f 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -327,10 +327,10 @@ export function Review({ team.has(p.data.user.login) ? `From ${p.data.user.login}, on your team — teammates’ PRs lead this lane` : p.qaingLogin - ? `${p.qaingLogin} is already testing it; it sinks below unclaimed QA` - : p.weight === 'XS' || p.weight === 'S' - ? `Nobody's testing it yet, a light one (${p.weight})` - : `Nobody's testing it yet, waiting ${Math.max(1, Math.round(p.ageDays))}d`; + ? `${p.qaingLogin} is already testing it; it sinks below unclaimed QA` + : p.weight === 'XS' || p.weight === 'S' + ? `Nobody's testing it yet, a light one (${p.weight})` + : `Nobody's testing it yet, waiting ${Math.max(1, Math.round(p.ageDays))}d`; const boardIsQuiet = !yourMove.length && @@ -498,8 +498,8 @@ export function Review({

Your team’s PRs always come first (edit your team on the Team tab), ordered - among themselves by this same score — being on your team is the boost; there - is no extra ranking between teammates. + among themselves by this same score — being on your team is the boost; there is + no extra ranking between teammates.

After those: PRs in repos you’ve reviewed before, PRs from people who review @@ -604,7 +604,7 @@ export function Review({ @@ -612,7 +612,7 @@ export function Review({ From e302acec4574bdad45e2b929565a8714f25a7808 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 16:51:06 -0700 Subject: [PATCH 323/381] Wordmark animates only on breakpoint crossings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The letters were also dealing in on every page load — a board opened ten times a day shouldn't perform an entrance. The component now mounts straight into its final state (no state change, so no transition) and the deal runs only when a resize carries the wordmark across the 2xl breakpoint, in or out of view. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/components/Wordmark.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/frontend-v2/src/components/Wordmark.tsx b/frontend-v2/src/components/Wordmark.tsx index 7d65fce3..115f9e23 100644 --- a/frontend-v2/src/components/Wordmark.tsx +++ b/frontend-v2/src/components/Wordmark.tsx @@ -9,26 +9,21 @@ import { useEffect, useState, type CSSProperties } from 'react'; * uses), but mounted always: the h1 is absolutely positioned, so neither * state touches layout. * - * Starts hidden on purpose and flips on the first painted frame — mounting - * straight into the shown state would skip the transition, and the deal-in - * is the page's one moment of arrival. + * Mounts straight into its final state on purpose: page load gets NO + * animation (a board you open ten times a day shouldn't perform an + * entrance) — the letters only deal when a resize carries the wordmark + * across the breakpoint, in or out of view. * * aria-hidden: the Logo beside it already carries the accessible name * "Pulldasher"; per-letter spans would only spell noise at a screen reader. */ export function Wordmark() { - const [shown, setShown] = useState(false); + const [shown, setShown] = useState(() => matchMedia('(min-width: 1536px)').matches); useEffect(() => { const mq = matchMedia('(min-width: 1536px)'); const follow = () => setShown(mq.matches); - // double rAF: the browser must paint the hidden state once before the - // flip, or there is nothing to transition from - const raf = requestAnimationFrame(() => requestAnimationFrame(follow)); mq.addEventListener('change', follow); - return () => { - cancelAnimationFrame(raf); - mq.removeEventListener('change', follow); - }; + return () => mq.removeEventListener('change', follow); }, []); return ( From 2cd2081e1af88d839b586dd292cd11a16310e61c Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 17:37:13 -0700 Subject: [PATCH 324/381] Fix the People filter's crushed names: except and Hide give way Danny's screenshot showed author names truncated to single letters ("B.", "m.") in the 300px People panel. The cause was layout math, not truncation: the hover-revealed "only"/"except" buttons hide via opacity, which still reserves width, and together with the worded Hide button ~200px of every row was chrome before the name got a say. "except" was the widest single word on the row. Two changes claw the width back: - The ExceptButton is deleted. The "everyone except" lane itself stays (hash xauthors=, saved searches, the -N trigger badge, the app-level filter all still work) - the gesture moves to shift-clicking the row's checkbox, taught by the checkbox title. The excluded state still stands: unchecked box, struck-through name, -N badge. The gesture rides onChange, not onClick: React synthesizes checkbox change events from clicks, so a preventDefault-ed onClick still fires onChange and the two scope writes race (verified live - the stale-closure toggle overwrote the exclusion with a 24-name allow-list). - The worded Hide/Show buttons in both filter lists become one shared EyeButton: an open eye on visible rows (click hides), a closed eye on hidden rows (click shows), brand tone for the show that overrides an org-level hide. Full words live in title/aria. The HiddenPanel's "Hide them again" stays worded - it's a batch ledger action where a bare icon would obscure what's about to happen. Verified live on the dummy board: full names render, shift-click writes xauthors= and unchecks the row, plain click re-includes, the eye hides a person into the Hidden fold and the closed eye restores them. Co-Authored-By: Claude Fable 5 --- .../src/components/filters/PeopleFilter.tsx | 62 +++++++++++-------- .../src/components/filters/RepoFilter.tsx | 20 +++--- frontend-v2/src/components/filters/shared.tsx | 39 ++++++++---- 3 files changed, 74 insertions(+), 47 deletions(-) diff --git a/frontend-v2/src/components/filters/PeopleFilter.tsx b/frontend-v2/src/components/filters/PeopleFilter.tsx index 82a2e73f..cf757e77 100644 --- a/frontend-v2/src/components/filters/PeopleFilter.tsx +++ b/frontend-v2/src/components/filters/PeopleFilter.tsx @@ -4,25 +4,20 @@ import { displayName, useNames } from '../../model/names'; import type { Scope } from '../../prefs'; import { toggleHiddenPerson, toggleTeammate, useSettings } from '../../settings'; import { usePulldasher } from '../../store'; -import { QuietButton } from '../bits'; import { Avatar, StarMark } from '../identity'; import { Popover } from '../Popover'; -import { - ClearRow, - ExceptButton, - FilterRow, - FilterSearch, - FilterTrigger, - OnlyButton, -} from './shared'; +import { ClearRow, EyeButton, FilterRow, FilterSearch, FilterTrigger, OnlyButton } from './shared'; /** * The people filter: a searchable author list where scope (on my board), - * except (everyone but them), star (floats to the front of my queues), and - * hide (off my board, period) live side by side on each row — the same - * scope/hide-together layout RepoFilter uses for repos. Whole-team narrowing - * lives in the pinned saved searches, not here: every roster already derives - * one, so this panel stays a per-person surface. + * star (floats to the front of my queues), and hide (off my board, period) + * live side by side on each row — the same scope/hide-together layout + * RepoFilter uses for repos. Whole-team narrowing lives in the pinned saved + * searches, not here: every roster already derives one, so this panel stays + * a per-person surface. The "everyone except" lane has no button of its own + * (a worded control per row was what crushed the name column): ⇧-clicking a + * row's checkbox excludes, and the struck-through row plus the trigger's −N + * badge stand for the choice. */ export function PeopleFilter({ pulls, @@ -136,14 +131,27 @@ export function PeopleFilter({ type="checkbox" className="m-0" checked={included(login) && !excluded} - onChange={() => + title={ + excluded + ? 'excluded — click to show their PRs again' + : '⇧ click: everyone except them' + } + // ⇧-click is the "everyone except" gesture. It rides the + // change event, not onClick: React synthesizes checkbox + // onChange from the click, so a preventDefault-ed onClick + // still fires it and the two writes race. The checkbox is + // controlled, so the render restores the right checked + // state either way. + onChange={e => { + if ((e.nativeEvent as MouseEvent).shiftKey) + return excluded ? include(login) : exclude(login); excluded ? include(login) : toggleScope( login, authors.map(([l]) => l) - ) - } + ); + }} /> setScope({ ...scope, authors: [login], notAuthors: [] })} /> - (excluded ? include(login) : exclude(login))} - /> {login !== me && ( - toggleHiddenPerson(login, true)}> - Hide - + - toggleHiddenPerson(login, false)}> - Show - +

diff --git a/frontend-v2/src/components/filters/RepoFilter.tsx b/frontend-v2/src/components/filters/RepoFilter.tsx index c5d74fe8..293b2344 100644 --- a/frontend-v2/src/components/filters/RepoFilter.tsx +++ b/frontend-v2/src/components/filters/RepoFilter.tsx @@ -3,10 +3,9 @@ import { repoState } from '../../model/visibility'; import { shortRepo } from '../../format'; import type { Scope } from '../../prefs'; import { setRepoPref, togglePrimaryRepo, useSettings } from '../../settings'; -import { QuietButton } from '../bits'; import { StarMark } from '../identity'; import { Popover } from '../Popover'; -import { ClearRow, FilterSearch, FilterTrigger, OnlyButton } from './shared'; +import { ClearRow, EyeButton, FilterSearch, FilterTrigger, OnlyButton } from './shared'; /** * The repos filter: opens straight into the repo list (no tabs — the old @@ -112,7 +111,11 @@ export function RepoFilter({ > - setRepoPref(name, 'hide')}>Hide +
); }; @@ -138,11 +141,14 @@ export function RepoFilter({ {count || ''} {state === 'hidden' ? ( - setRepoPref(name, null)}>Show +
); diff --git a/frontend-v2/src/components/filters/shared.tsx b/frontend-v2/src/components/filters/shared.tsx index 08323c8a..e117acdd 100644 --- a/frontend-v2/src/components/filters/shared.tsx +++ b/frontend-v2/src/components/filters/shared.tsx @@ -1,5 +1,5 @@ import type { ChangeEvent, ReactNode } from 'react'; -import { ChevronDown } from 'lucide-react'; +import { ChevronDown, Eye, EyeClosed } from 'lucide-react'; import { Icon } from '../Icon'; import type { PopoverTriggerProps } from '../Popover'; import { CornerBadge, QuietButton } from '../bits'; @@ -168,28 +168,41 @@ export function OnlyButton({ onClick }: { onClick: () => void }) { } /** - * "only"'s inverse, People rows only: flip this person into the "everyone - * except" scope. An excluded row's button STANDS (visible without hover, - * beside the struck-through name) because it records a choice; clicking it - * again re-includes them. + * The row-level visibility toggle both filter lists share, replacing the + * worded Hide/Show pair: an open eye on a visible row (click hides), a + * closed eye on a hidden one (click shows). Icon-only on purpose — even + * hover-hidden words reserve layout width, and on a 300px panel the worded + * buttons were what crushed the name column. Full words live in title/aria. + * `tone="brand"` marks the show that overrides an org-level hide. */ -export function ExceptButton({ on, onClick }: { on: boolean; onClick: () => void }) { +export function EyeButton({ + hidden, + subject, + onClick, + tone = 'quiet', +}: { + /** the row's current state: hidden rows wear the closed eye */ + hidden: boolean; + /** what the title/aria name — a repo short name or a login */ + subject: string; + onClick: () => void; + tone?: 'quiet' | 'brand'; +}) { + const words = hidden ? `show ${subject} again` : `hide ${subject} from the board`; return ( ); } From b4ec80b3ea5b680b6399caa378fd74f72ad81ec3 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 17:59:36 -0700 Subject: [PATCH 325/381] Retire the starred concept; teammates wear a heart The star used to mean three different things: a repo-level "primary repo" toggle, a person-level "add to your team" toggle, and the read-only identity marks (corner star = teammate, seated star = you). Same glyph, unrelated effects - the owner's verdict was that the star-to-sorting link was illegible and the repo star near-useless in a monorepo. The star now means exactly one thing: YOU. - The repo star is gone, control and setting. Review's primary/other split always uses the inferred set (repos you've authored or stamped on the current board), which the manual list silently REPLACED before: starring one minor repo folded your main repo out of the queue. Repo-level intent keeps one lever: hide. - The people-star controls are gone from the People filter and the row kebab. Rosters change in one place, the Team lens. The read-only mark survives as a lucide Heart at the avatar corner - same seat and size as the you-star (13px at -3 on the 22px row avatar) so the two identity marks carry equal weight, per the owner's call. Its title names the roster(s), and the avatar hover card gains an "On " line, so why-this-row-leads is attributed in place. - No user-facing text says star/starred/unstar anymore; the Legend keeps "star" only to describe the you-seal glyph. starve/starvation vocabulary (queue fairness) is unrelated and untouched. Candidate glyphs (rocket, handshake, heart-hands) were tested on the live board at the mark's real 9-13px: only solid silhouettes survive, and hand/rocket glyphs collide with the claim-hand and shipping vocabularies. Heart won. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 24 +++-- frontend-v2/src/components/Legend.tsx | 6 +- frontend-v2/src/components/Row.tsx | 63 +++---------- frontend-v2/src/components/Settings.tsx | 15 ++- .../src/components/filters/PeopleFilter.tsx | 47 ++-------- .../src/components/filters/RepoFilter.tsx | 91 +++++++------------ frontend-v2/src/components/filters/shared.tsx | 6 +- frontend-v2/src/components/identity.tsx | 40 ++++---- frontend-v2/src/model/deal.ts | 2 +- frontend-v2/src/model/sort.test.ts | 6 +- frontend-v2/src/settings.ts | 15 +-- frontend-v2/src/views/Review.tsx | 23 ++--- 12 files changed, 122 insertions(+), 216 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index d1082618..5d0d3e87 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -126,7 +126,7 @@ a card is where it is, one of these layers is missing. ## Identity vs standing -**The silhouette and the star** — the author-identity system, two +**The silhouette and the corner mark** — the author-identity system, two channels and no ring: - **Shape answers person-or-machine.** People are circles; bots (a @@ -135,15 +135,21 @@ channels and no ring: the outline is the whole mark, so it reads at 16px and in peripheral vision. - **The bottom-right corner answers what-this-person-is-to-you, in one - glyph.** A small (9px) brand star on someone you starred; on your own - avatar, a larger (13px/11px) brand star seated ON the rim with a - transparent bite masked out of the face — your silhouette is visibly - broken, so your rows are findable by *form* before the star even - resolves. You are the star vocabulary's largest case, escalated by - size and a broken outline, never by fill. The bite is a mask, not a - painted stroke, so it stays correct over any row background (hover, - fresh flash). + of two glyphs.** A small (9px) filled brand heart on a teammate — + someone on one of your rosters; on your own avatar, a larger + (13px/11px) brand star seated ON the rim with a transparent bite + masked out of the face — your silhouette is visibly broken, so your + rows are findable by *form* before the star even resolves. The star + means exactly one thing now: you. You are the vocabulary's largest + case, escalated by size and a broken outline, never by fill. The bite + is a mask, not a painted stroke, so it stays correct over any row + background (hover, fresh flash). - Static always: identity, not state. Never animates. +- **The controls are retired, not just renamed.** There is no more + "starred repo" or "starred person" toggle: repo relevance is inferred + from the current board (repos you've authored or stamped on), and + roster membership is edited only on the Team lens, not from a row or + filter-row button. Rejected on the way here, in order: the plain brand ring (selection halo), a corner dot (online presence), a brand repo#number (reads as a diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx index 002dbc9a..6c2da142 100644 --- a/frontend-v2/src/components/Legend.tsx +++ b/frontend-v2/src/components/Legend.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { CircleHelp, Star } from 'lucide-react'; +import { CircleHelp, Heart, Star } from 'lucide-react'; import { Icon } from './Icon'; import { Popover } from './Popover'; import { eyebrowText } from './WordGroups'; @@ -126,11 +126,11 @@ export function Legend() { - + } - def="a small corner star marks someone on your team; the larger star seated on your own avatar’s rim is you" + def="a corner heart marks someone on your team; the star seated on your own avatar’s rim is you" /> diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx index 599ed343..2003e2d9 100644 --- a/frontend-v2/src/components/Row.tsx +++ b/frontend-v2/src/components/Row.tsx @@ -7,8 +7,8 @@ import { Eye, EyeOff, Hand, + Heart, RefreshCw, - Star, } from 'lucide-react'; import type { DerivedPull } from '../model/status'; import { isIterating, lastPushEpoch, weightFilterKey } from '../model/status'; @@ -17,13 +17,7 @@ import { matchedRegions } from '../model/regions'; import { claimFor } from '../model/reviewers'; import type { ParentRef } from '../model/stack'; import { ago, epoch, pullKey, rowDomId, shortRepo } from '../format'; -import { - setRepoPref, - toggleHiddenPerson, - togglePrimaryRepo, - toggleTeammate, - useSettings, -} from '../settings'; +import { setRepoPref, toggleHiddenPerson, useSettings } from '../settings'; import { claimReview, isFresh, @@ -37,7 +31,6 @@ import { import { AgeStamp, AgeBaseline } from './age'; import { DiffSize, QuietButton, RepoRef, WEIGHT_WORD } from './bits'; import { CardShell } from './Card'; -import { StarMark } from './identity'; import { CiStatus, SigPips } from './pips'; import { Icon } from './Icon'; import { StatePopover } from './StatePopover'; @@ -402,9 +395,7 @@ function RowActionsKebab({ const repo = pull.data.repo; const author = pull.data.user.login; const repoLabel = shortRepo(repo); - const isPrimaryRepo = settings.primaryRepos.includes(repo); const isHiddenRepo = settings.repoPrefs[repo] === 'hide'; - const isTeammate = settings.teams.some(t => t.members.includes(author)); const claimedByMe = claim?.login === me; const item = 'flex w-full items-center gap-2 rounded-md border-0 bg-transparent px-2 py-2 text-left text-xs text-ink-2 hover:bg-muted'; @@ -472,20 +463,6 @@ function RowActionsKebab({ Re-fetch from GitHub
- - {/* never offered for your own pulls — you can't hide yourself from your own board */} {author !== me && ( @@ -688,11 +651,11 @@ function RowImpl({ const note = rowNote(pull, opts.me, { claim, turn }); // the wait badge may itself carry a "last commit …" — don't say it twice const showIterating = isIterating(pull) && !(note.context ?? '').includes('last commit'); - // the smallest clean marker for a teammate: a tiny ★ over their avatar, - // so the row itself says "one of your people" without a trip to the - // kebab menu + // the smallest clean marker for a teammate: a tiny heart over their + // avatar, so the row itself says "one of your people" without a trip to + // the kebab menu const settings = useSettings(); - const teamAuthor = settings.teams.some(t => t.members.includes(d.user.login)); + const rosters = settings.teams.filter(t => t.members.includes(d.user.login)).map(t => t.name); // which of your code regions this pull matched (why it floated to the top) const regions = matchedRegions(pull, settings.codeRegions); // only worth asking the whole-board lookup when this row is stacked but @@ -715,14 +678,18 @@ function RowImpl({ className={`${flashOnce(key, !!fresh) ? 'row-fresh' : ''} transition-[background-color] duration-150 ease-out motion-reduce:transition-none`} avatarBadge={ // your own rows never wear it: the you-star owns that corner, - // and star-on-yourself would double the glyph - teamAuthor && + // and a heart-on-yourself would double the glyph + rosters.length > 0 && d.user.login !== opts.me && ( - + ) } diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 548d7791..0bc5e2ef 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -216,13 +216,12 @@ export function Settings({ tabIndex={-1} className="settings-panel absolute top-0 right-0 flex h-full w-[min(360px,100vw)] flex-col overflow-y-auto border-l border-line bg-surface shadow-xl outline-none" > - {/* z-10: children further down the panel (avatars, star - buttons, the code-regions Add button — anything - position:relative via .hit/.pressable) paint above a - sticky header with no z-index of its own once they - scroll under it. The overlay above is already - z-[100], so z-10 in here only has to beat panel - content. */} + {/* z-10: children further down the panel (avatars, the + code-regions Add button — anything position:relative + via .hit/.pressable) paint above a sticky header with + no z-index of its own once they scroll under it. The + overlay above is already z-[100], so z-10 in here + only has to beat panel content. */}
Settings
- Repos: star and hide from the Repos filter in the header. + Repos: hide and reveal from the Repos filter in the header. diff --git a/frontend-v2/src/components/filters/PeopleFilter.tsx b/frontend-v2/src/components/filters/PeopleFilter.tsx index cf757e77..172a337a 100644 --- a/frontend-v2/src/components/filters/PeopleFilter.tsx +++ b/frontend-v2/src/components/filters/PeopleFilter.tsx @@ -2,22 +2,21 @@ import { useState } from 'react'; import type { DerivedPull } from '../../model/status'; import { displayName, useNames } from '../../model/names'; import type { Scope } from '../../prefs'; -import { toggleHiddenPerson, toggleTeammate, useSettings } from '../../settings'; +import { toggleHiddenPerson, useSettings } from '../../settings'; import { usePulldasher } from '../../store'; -import { Avatar, StarMark } from '../identity'; +import { Avatar } from '../identity'; import { Popover } from '../Popover'; import { ClearRow, EyeButton, FilterRow, FilterSearch, FilterTrigger, OnlyButton } from './shared'; /** - * The people filter: a searchable author list where scope (on my board), - * star (floats to the front of my queues), and hide (off my board, period) - * live side by side on each row — the same scope/hide-together layout - * RepoFilter uses for repos. Whole-team narrowing lives in the pinned saved - * searches, not here: every roster already derives one, so this panel stays - * a per-person surface. The "everyone except" lane has no button of its own - * (a worded control per row was what crushed the name column): ⇧-clicking a - * row's checkbox excludes, and the struck-through row plus the trigger's −N - * badge stand for the choice. + * The people filter: a searchable author list where scope (on my board) and + * hide (off my board, period) live side by side on each row — the same + * scope/hide-together layout RepoFilter uses for repos. Whole-team narrowing + * lives in the pinned saved searches, not here: every roster already derives + * one, so this panel stays a per-person surface. The "everyone except" lane + * has no button of its own (a worded control per row was what crushed the + * name column): ⇧-clicking a row's checkbox excludes, and the struck-through + * row plus the trigger's −N badge stand for the choice. */ export function PeopleFilter({ pulls, @@ -36,7 +35,6 @@ export function PeopleFilter({ const nameOf = (login: string) => displayName(namesMap, login); const matchesPerson = (login: string, q: string) => login.toLowerCase().includes(q) || (nameOf(login) ?? '').toLowerCase().includes(q); - const teamSet = new Set(settings.teams.flatMap(t => t.members)); const hiddenSet = new Set(settings.hiddenPeople); const [peopleQuery, setPeopleQuery] = useState(''); @@ -122,7 +120,6 @@ export function PeopleFilter({ > {filteredShown.map(([login, count]) => { - const isTeammate = teamSet.has(login); const excluded = scope.notAuthors.includes(login); return ( @@ -167,30 +164,6 @@ export function PeopleFilter({ setScope({ ...scope, authors: [login], notAuthors: [] })} /> - {login !== me && (
+ ); const hiddenRow = (name: string, count: number, state: 'hidden' | 'org-hidden') => (
* right now — what the bar's "Reset" resets, and what the saved-filters * panel reads to decide whether there's anything worth bookmarking. One * definition so the two surfaces can't disagree about what counts as - * "active". Durable state (hidden, stars, defaults) is deliberately not here. + * "active". Durable state (hidden, defaults) is deliberately not here. */ export function hasActiveFilters({ reveal, @@ -149,8 +149,8 @@ export function FilterRow({ children }: { children: ReactNode }) { * The "only" quick-action: narrows a filter-picker's selection to exactly * this one row's item, deselecting everything else. Hidden until the row is * hovered or the button itself is keyboard-focused, so it never crowds the - * row's existing checkbox/star/hide buttons while still being reachable - * without a mouse. Mirrors v1's filter "only" feature. + * row's existing checkbox/hide buttons while still being reachable without a + * mouse. Mirrors v1's filter "only" feature. */ export function OnlyButton({ onClick }: { onClick: () => void }) { return ( diff --git a/frontend-v2/src/components/identity.tsx b/frontend-v2/src/components/identity.tsx index c0a40094..b83e6ada 100644 --- a/frontend-v2/src/components/identity.tsx +++ b/frontend-v2/src/components/identity.tsx @@ -1,25 +1,26 @@ import { useEffect, useState } from 'react'; import type { CSSProperties } from 'react'; -import { Star } from 'lucide-react'; +import { Heart, Star } from 'lucide-react'; import { githubAvatarUrl, githubProfileUrl, loginHue } from '../format'; import { displayName, requestNames, useNames } from '../model/names'; +import { useSettings } from '../settings'; import { Icon } from './Icon'; import { Popover } from './Popover'; /** - * The board's author-identity system — the silhouette and the star: + * The board's author-identity system — the silhouette and the corner mark: * * - SHAPE answers "person or machine": people are circles, bots (GitHub * Apps and config-listed machine accounts) are rounded squares, the * app-tile idiom Slack and GitHub already taught. No glyph, no hue — the * outline is the mark, so it reads at 16px and in peripheral vision. - * - The BOTTOM-RIGHT CORNER answers "what is this person to you", in one - * glyph: a small star on a teammate (your review circle), and a larger star seated on - * the rim of your own avatar with a bite masked out of the face, so your - * silhouette is visibly broken. You are the star vocabulary's largest - * case — escalated by size and form, never by fill. (The ringed seal and - * the v1 star-coin both died here: a halo reads as focus, a coin loses - * the face.) + * - The BOTTOM-RIGHT CORNER answers "what is this person to you", in one of + * two glyphs: a small heart on a teammate (your review circle), and a + * larger star seated on the rim of your own avatar with a bite masked out + * of the face, so your silhouette is visibly broken. The star's only + * meaning is you — you are the vocabulary's largest case, escalated by + * size and form, never by fill. (The ringed seal and the v1 star-coin + * both died here: a halo reads as focus, a coin loses the face.) * - Identity, never state: none of it animates or changes with PR status. */ @@ -99,6 +100,11 @@ function PersonCard({ login }: { login: string }) { const names = useNames(); useEffect(() => requestNames([login]), [login]); const name = displayName(names, login); + // teammate attribution is one hover away anywhere an avatar is clickable, + // so a row's heart mark never has to be the only place that tells you + const rosters = useSettings() + .teams.filter(t => t.members.includes(login)) + .map(t => t.name); return (
@@ -120,6 +126,12 @@ function PersonCard({ login }: { login: string }) { > {name ? `@${login} · ` : ''}GitHub profile ↗ + {rosters.length > 0 && ( +
+ + On {rosters.join(' & ')} +
+ )}
); @@ -196,13 +208,3 @@ export function Avatar({ ); } - -/** - * The one star mark every "primary repo" / "teammate" toggle shares - * (Row's kebab menu, RepoFilter, PeopleFilter, RepoManager): filled when on, - * outline when off, same lucide glyph everywhere instead of five hand-rolled - * ★/☆ copies. - */ -export function StarMark({ on, size = 14 }: { on: boolean; size?: number }) { - return ; -} diff --git a/frontend-v2/src/model/deal.ts b/frontend-v2/src/model/deal.ts index 0dea5712..b8e56e52 100644 --- a/frontend-v2/src/model/deal.ts +++ b/frontend-v2/src/model/deal.ts @@ -83,7 +83,7 @@ export function dealRank(pool: DerivedPull[], opts: DealRankOptions): DerivedPul /** * Deal the first still-available entry of an already-ranked list — skipping * anything claimed or already passed this sitting. Takes the *rendered* queue - * (dealRank order, star-pinning and all) rather than re-ranking, so the card + * (dealRank order, team-pinning and all) rather than re-ranking, so the card * dealt is by construction the top visible one still up for grabs. */ export function dealFrom( diff --git a/frontend-v2/src/model/sort.test.ts b/frontend-v2/src/model/sort.test.ts index 1ec9d9a2..568ccf2e 100644 --- a/frontend-v2/src/model/sort.test.ts +++ b/frontend-v2/src/model/sort.test.ts @@ -57,7 +57,7 @@ function withAuthor(login: string): DerivedPull { } describe('teamFirst', () => { - it('moves starred authors to the front', () => { + it('moves team authors to the front', () => { const a = withAuthor('alice'); const b = withAuthor('bob'); const c = withAuthor('carol'); @@ -66,7 +66,7 @@ describe('teamFirst', () => { expect(sorted.slice(1)).toEqual([a, b]); }); - it('is stable within the starred group and within the rest', () => { + it('is stable within the team group and within the rest', () => { const a1 = withAuthor('alice'); const a2 = withAuthor('alice'); const b1 = withAuthor('bob'); @@ -76,7 +76,7 @@ describe('teamFirst', () => { expect(sorted).toEqual([a1, a2, b1, b2]); }); - it('leaves the list untouched when nobody is starred', () => { + it('leaves the list untouched when nobody is on the team', () => { const a = withAuthor('alice'); const b = withAuthor('bob'); expect(teamFirst([a, b], new Set())).toEqual([a, b]); diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index fe90489c..5522ee1a 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -71,11 +71,6 @@ export interface Settings { * real stall: surface "Find a QA-er" on your own PR as a home to-do, not a * My-work afterthought. Off leaves getting QA in My work only. */ selfReview: boolean; - /** the repos you actually review, so the review queue leads with them and - * folds the rest away. Repo relevance is per-person (a web dev and a - * firmware dev share a monorepo but little else). Empty = infer from the - * repos where you've authored or stamped on the current board. */ - primaryRepos: string[]; /** your named rosters — REVIEW CIRCLES, yours to define, not the org * chart (an official team and a cross-team pairing partner can be two * separate rosters). Every roster's members get the same two effects: @@ -123,7 +118,6 @@ export const DEFAULT_SETTINGS: Settings = { laneCap: 10, laneCapByLens: {}, selfReview: true, - primaryRepos: [], teams: [], hiddenPeople: [], codeRegions: [], @@ -135,7 +129,7 @@ export const DEFAULT_SETTINGS: Settings = { const store = createPersistentStore('pd2.settings', DEFAULT_SETTINGS); /** Union of every personal roster, sorted — "your people": the set that - * leads the review queues and wears the teammate corner star. */ + * leads the review queues and wears the teammate corner heart. */ export function myPeople(teams: PersonalTeam[]): string[] { return [...new Set(teams.flatMap(t => t.members))].sort(); } @@ -165,13 +159,6 @@ export function setLaneCapForLens(lens: string, cap: number | null) { setSettings({ laneCapByLens: next }); } -/** Add or remove a repo from your primary (actively-reviewed) set. */ -export function togglePrimaryRepo(repo: string, primary: boolean) { - const cur = store.get().primaryRepos; - const next = primary ? [...new Set([...cur, repo])] : cur.filter(r => r !== repo); - setSettings({ primaryRepos: next }); -} - /** Add or remove a teammate. Adding lands in `teamName` (or the first * roster, creating "My team" when none exists — the quick gesture from a row * kebab must never dead-end on an empty state). Removing without a teamName diff --git a/frontend-v2/src/views/Review.tsx b/frontend-v2/src/views/Review.tsx index 5749912f..2d39290d 100644 --- a/frontend-v2/src/views/Review.tsx +++ b/frontend-v2/src/views/Review.tsx @@ -43,7 +43,7 @@ export function Review({ opts: RowOptions; }) { const me = opts.me; - const { selfReview, primaryRepos, teams, codeRegions } = useSettings(); + const { selfReview, teams, codeRegions } = useSettings(); const team = new Set(myPeople(teams)); // A snooze is "not today" for THIS lens only: the daily what-do-I-review // loop lives here, so the quieting gesture belongs here — every other @@ -55,18 +55,15 @@ export function Review({ const bots = allBots.filter(p => !isSnoozed(p.data, snoozed)); const others = pulls.filter(p => p.data.user.login !== me); - // Repo relevance is per-person: a web dev and a firmware dev share the - // monorepo but little else. Your primary repos are the ones you set, or — - // until you set any — the ones you're demonstrably in (authored or stamped - // on the current board). An empty set means we can't tell, so treat every - // repo as primary and fall back to one flat queue. - const primarySet = primaryRepos.length - ? new Set(primaryRepos) - : new Set( - pulls - .filter(p => p.data.user.login === me || p.crBy.includes(me) || p.qaBy.includes(me)) - .map(p => p.data.repo) - ); + // Repo relevance is per-person (a web dev and a firmware dev share the + // monorepo but little else) and inferred from the current board: the + // repos you've authored or stamped on. An empty inference means we can't + // tell, so every repo counts as primary and the queue stays flat. + const primarySet = new Set( + pulls + .filter(p => p.data.user.login === me || p.crBy.includes(me) || p.qaBy.includes(me)) + .map(p => p.data.repo) + ); const isPrimaryRepo = (repo: string) => primarySet.size === 0 || primarySet.has(repo); // 1. Waiting on you: strictly your verbs. The earlier "Act now" lesson still From 9d61318e9ae49e868880bfe4911a72f7536023fb Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 18:07:13 -0700 Subject: [PATCH 326/381] Mute for real: per-user hides only, and scope stops persisting Danny's "muted repo still shows its pulls" prod report, root-caused live: two cooperating defects. First, scope was persisted twice. The hash is the view state, but a localStorage shadow copy (pd2.scope) re-applied itself on every hash-less load - and a months-old allow-list naming a hidden repo counts as an "explicit reveal", so the repo's pulls stayed on the board no matter how often it was hidden, then the state effect wrote the stale scope back into the hash. Reproduced exactly with a seeded pd2.scope on the dummy board. Scope now lives in memory + the hash only (createMemoryStore); the old key is deleted on boot so nothing can mistake it for live state. Opening a bare URL now always means a clean, unscoped board. Second, the org-level mute itself is retired, per the owner: the server config's v1-era hideByDefault flag is now ignored client-side. Org muting was a v1 concept that v2 half-honored - the client knew the state but a reveal could permanently defeat it, and nobody could tell which of the two systems was in charge. Now there is one: each user hides a repo once (the eye in the Repos filter), their call, stored in their own prefs. repoState's three-way logic collapses into a plain two-state repoHidden; the "org" chip, the brand-tone override-show, and the org set plumbing are gone. The filters' hidden folds also gain a rotating chevron caret - the bare "Hidden by you (N)" summary read as a label, not a door (owner report). Verified live: a seeded stale scope no longer resurrects (key deleted, board unscoped), hash scope still applies, the eye-hide survives reload with pulls withheld and the ledger counting them. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 41 +++++--------- .../src/components/filters/HiddenPanel.tsx | 2 +- .../src/components/filters/PeopleFilter.tsx | 13 ++++- .../src/components/filters/RepoFilter.tsx | 55 ++++++++----------- frontend-v2/src/model/visibility.ts | 42 ++++---------- frontend-v2/src/prefs.ts | 30 +++++----- frontend-v2/src/settings.ts | 5 +- frontend-v2/src/storage.ts | 32 +++++++++++ frontend-v2/src/types.ts | 3 +- 9 files changed, 116 insertions(+), 107 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 2b55bcab..ebf8a065 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -205,7 +205,6 @@ export function App() { const { pulls, closed, - repoSpecs, me, connection, initialized, @@ -423,11 +422,6 @@ export function App() { return () => document.removeEventListener('keydown', onKey); }, []); - const hiddenRepos = useMemo( - () => new Set(repoSpecs.filter(s => s.hideByDefault).map(s => s.name)), - [repoSpecs] - ); - // scope/query applied, but NOT the changed-only toggle: the changed count // must describe the pool the toggle would narrow, or the banner promises // rows the click doesn't deliver @@ -457,19 +451,19 @@ export function App() { [scope.authors, queryAuthors] ); - // The one is-this-pull-off-the-board predicate: user-hidden and - // org-hidden repos, hidden people, cryo, snoozes, and the drafts rule stay - // off unless a session act reveals them — an explicit reveal, a scope, a - // repo:/author: query term, or the master "show everything". Never hides - // your own pulls or bots via hides: bots have their own fold, and a person - // can't hide themselves from their own board. Shared by the filter pipeline - // and the hidden-PR ledger's counts so the ledger's number can never - // disagree with what the board actually withholds. + // The one is-this-pull-off-the-board predicate: hidden repos, hidden + // people, cryo, snoozes, and the drafts rule stay off unless a session + // act reveals them — an explicit reveal, a scope, a repo:/author: query + // term, or the master "show everything". Never hides your own pulls or + // bots via hides: bots have their own fold, and a person can't hide + // themselves from their own board. Shared by the filter pipeline and the + // hidden-PR ledger's counts so the ledger's number can never disagree + // with what the board actually withholds. const boardHidden = useCallback( (p: DerivedPull) => { if (showAll) return false; const hiddenRepo = - repoHidden(p.data.repo, hiddenRepos, settings.repoPrefs) && !revealedRepo(p.data.repo); + repoHidden(p.data.repo, settings.repoPrefs) && !revealedRepo(p.data.repo); const hiddenPerson = p.data.user.login !== me && !isBot(p) && @@ -497,7 +491,6 @@ export function App() { reveal, revealedRepo, revealedAuthor, - hiddenRepos, me, isBot, settings.repoPrefs, @@ -550,16 +543,15 @@ export function App() { const humans = useMemo(() => scoped.filter(p => !isBot(p)), [scoped, isBot]); const bots = useMemo(() => scoped.filter(isBot), [scoped, isBot]); // every known repo with its open-PR count — feeds the Filters popover and - // the Settings repo manager. Includes org-hidden and pref'd repos at 0. + // the Settings repo manager. Includes pref'd repos at 0. const repoCounts = useMemo(() => { const m = new Map(); - for (const name of hiddenRepos) m.set(name, 0); - for (const name of Object.keys(settings.repoPrefs)) if (!m.has(name)) m.set(name, 0); + for (const name of Object.keys(settings.repoPrefs)) m.set(name, 0); for (const p of pulls) m.set(p.data.repo, (m.get(p.data.repo) ?? 0) + 1); return [...m.entries()] .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); - }, [pulls, hiddenRepos, settings.repoPrefs]); + }, [pulls, settings.repoPrefs]); // "recently shipped" must follow the same scope as the open lanes above it — // narrowing to one repo or author shouldn't still show the whole org's @@ -569,8 +561,7 @@ export function App() { let out = closed; if (!showAll) out = out.filter(p => { - const hiddenRepo = - repoHidden(p.repo, hiddenRepos, settings.repoPrefs) && !revealedRepo(p.repo); + const hiddenRepo = repoHidden(p.repo, settings.repoPrefs) && !revealedRepo(p.repo); const hiddenPerson = p.user.login !== me && !isBotLogin(p.user.login, extraBots) && @@ -584,7 +575,6 @@ export function App() { }, [ closed, showAll, - hiddenRepos, settings.repoPrefs, settings.hiddenPeople, revealedRepo, @@ -602,7 +592,7 @@ export function App() { for (const p of pulls) { if (p.cryo) c.parked++; if (p.data.draft && p.data.user.login !== me && !reviewRequestedFrom(p, me)) c.drafts++; - if (repoHidden(p.data.repo, hiddenRepos, settings.repoPrefs)) c.hiddenRepos++; + if (repoHidden(p.data.repo, settings.repoPrefs)) c.hiddenRepos++; if ( p.data.user.login !== me && !isBot(p) && @@ -612,7 +602,7 @@ export function App() { if (boardHidden(p)) c.hiddenNow++; } return c; - }, [pulls, me, hiddenRepos, settings.repoPrefs, settings.hiddenPeople, isBot, boardHidden]); + }, [pulls, me, settings.repoPrefs, settings.hiddenPeople, isBot, boardHidden]); const isScoped = scope.repos.length || scope.authors.length || query; @@ -956,7 +946,6 @@ export function App() { })} No one matches.
)} {filteredHidden.length > 0 && ( -
- +
+ + {/* the caret is the fold affordance — without it this row + read as a label, not a door (owner report) */} + Hidden people ({filteredHidden.length}) {filteredHidden.map(([login, count]) => ( diff --git a/frontend-v2/src/components/filters/RepoFilter.tsx b/frontend-v2/src/components/filters/RepoFilter.tsx index 2b5fe864..f820d556 100644 --- a/frontend-v2/src/components/filters/RepoFilter.tsx +++ b/frontend-v2/src/components/filters/RepoFilter.tsx @@ -1,8 +1,10 @@ import { useState } from 'react'; -import { repoState } from '../../model/visibility'; +import { ChevronRight } from 'lucide-react'; +import { repoHidden } from '../../model/visibility'; import { shortRepo } from '../../format'; import type { Scope } from '../../prefs'; import { setRepoPref, useSettings } from '../../settings'; +import { Icon } from '../Icon'; import { Popover } from '../Popover'; import { ClearRow, EyeButton, FilterSearch, FilterTrigger, OnlyButton } from './shared'; @@ -11,13 +13,14 @@ import { ClearRow, EyeButton, FilterSearch, FilterTrigger, OnlyButton } from './ * 3-tab Filters popover split repos/people/drafts behind a Segmented click * you had to make before you could even search). Scope (which repos are on * your board right now) and hide (which repos are off your board, period) - * live side by side on each row. (Parked PRs and drafts used to ride along - * here as session toggles; they're board-level hiding, so they live in the - * filter bar's hidden-PR ledger now — see HiddenPanel.) + * live side by side on each row. Hiding is per-user only: the server + * config's v1-era org-level mute is ignored — each user hides a repo once, + * their call. (Parked PRs and drafts used to ride along here as session + * toggles; they're board-level hiding, so they live in the filter bar's + * hidden-PR ledger now — see HiddenPanel.) */ export function RepoFilter({ repos, - orgHidden, reveal, toggleReveal, showAll, @@ -25,9 +28,8 @@ export function RepoFilter({ scope, setScope, }: { - /** all repos with open-PR counts (org-hidden and user-hidden included) */ + /** all repos with open-PR counts (user-hidden included) */ repos: { name: string; count: number }[]; - orgHidden: ReadonlySet; reveal: string[]; toggleReveal: (key: string) => void; showAll: boolean; @@ -39,8 +41,8 @@ export function RepoFilter({ const prefs = settings.repoPrefs; const [repoQuery, setRepoQuery] = useState(''); - const shownRepos = repos.filter(r => repoState(r.name, orgHidden, prefs) === 'shown'); - const hiddenRepos = repos.filter(r => repoState(r.name, orgHidden, prefs) !== 'shown'); + const shownRepos = repos.filter(r => !repoHidden(r.name, prefs)); + const hiddenRepos = repos.filter(r => repoHidden(r.name, prefs)); const commit = (next: string[], all: string[]) => setScope({ ...scope, repos: all.length && next.length === all.length ? [] : next }); @@ -95,7 +97,7 @@ export function RepoFilter({
); - const hiddenRow = (name: string, count: number, state: 'hidden' | 'org-hidden') => ( + const hiddenRow = (name: string, count: number) => (
{shortRepo(name)} - {state === 'org-hidden' && org} {count || ''} - {state === 'hidden' ? ( -
); @@ -157,9 +149,16 @@ export function RepoFilter({ reveal is the first thing you reach — matching the Settings repo manager's hidden→shown order */} {filteredHidden.length > 0 && ( -
- - Hidden by you & org-hidden ({filteredHidden.length}) +
+ + {/* the caret is the fold affordance — without it this row + read as a label, not a door (owner report) */} + + Hidden by you ({filteredHidden.length}) - {filteredHidden.map(r => - hiddenRow( - r.name, - r.count, - repoState(r.name, orgHidden, prefs) as 'hidden' | 'org-hidden' - ) - )} + {filteredHidden.map(r => hiddenRow(r.name, r.count))}
)} {filteredShown.map(r => shownRow(r.name, r.count))} diff --git a/frontend-v2/src/model/visibility.ts b/frontend-v2/src/model/visibility.ts index 3d8ea294..0cb2d48b 100644 --- a/frontend-v2/src/model/visibility.ts +++ b/frontend-v2/src/model/visibility.ts @@ -1,43 +1,23 @@ /** * The one place "is this repo (or person) on my board by default" is * decided, shared by the filter pipeline and the filter controls so they can - * never disagree. For repos, three parties vote in a fixed order of - * authority — a per-user override beats the org baseline — and a session - * reveal (scope, query, the show= set) can still override the result for - * right now (that lives in app.tsx, not here). People have no org baseline: - * hiding is a plain two-state toggle. + * never disagree. Hiding is per-user and two-state for repos and people + * alike: the server config's v1-era `hideByDefault` flag is deliberately + * ignored (org-level muting was retired — each user hides a repo once, + * their call). A session reveal (scope, query, the show= set) can still + * override the result for right now; that lives in app.tsx, not here. */ /** the reveal-set sentinel for Cryogenic-Storage PRs (repo names fill the rest) */ export const CRYO_KEY = 'cryo'; -export type RepoState = 'shown' | 'hidden' | 'org-hidden'; - type RepoPrefs = Record; -/** - * The durable state of a repo: your override wins, else the org baseline. - * 'hidden' = you hid it; 'org-hidden' = off by default for everyone and you - * haven't unhidden it; 'shown' = on your board. - */ -export function repoState( - repo: string, - orgHidden: ReadonlySet, - prefs: RepoPrefs -): RepoState { - const pref = prefs[repo]; - if (pref === 'hide') return 'hidden'; - if (pref === 'show') return 'shown'; - return orgHidden.has(repo) ? 'org-hidden' : 'shown'; -} - -/** Hidden by default (you hid it, or org-hidden) — before any session reveal. */ -export function repoHidden( - repo: string, - orgHidden: ReadonlySet, - prefs: RepoPrefs -): boolean { - return repoState(repo, orgHidden, prefs) !== 'shown'; +/** You hid it — before any session reveal. Values other than 'hide' + * (including the org-era 'show' still parked in old stored prefs) mean + * shown. */ +export function repoHidden(repo: string, prefs: RepoPrefs): boolean { + return prefs[repo] === 'hide'; } /** GitHub Apps carry a [bot] suffix; other machine accounts are named in @@ -48,7 +28,7 @@ export function isBotLogin(login: string, extra: ReadonlySet): boolean { } /** A person has no org baseline the way a repo does — hiding is a plain - * two-state toggle, not a three-way state like repoState. */ + * two-state toggle. */ export function personHidden(login: string, hidden: string[]): boolean { return hidden.includes(login); } diff --git a/frontend-v2/src/prefs.ts b/frontend-v2/src/prefs.ts index eb4db305..d9376459 100644 --- a/frontend-v2/src/prefs.ts +++ b/frontend-v2/src/prefs.ts @@ -1,13 +1,17 @@ import type { Weight } from './model/status'; -import { createPersistentStore } from './storage'; +import { createMemoryStore, removeStorage } from './storage'; const WEIGHTS: ReadonlySet = new Set(['XS', 'S', 'M', 'L', 'XL']); /** - * Scope is the one saved customization: which repos and people are "my - * board". Empty arrays mean everything — a full selection is stored as - * empty so new repos and new teammates are never silently excluded by a - * stale saved list. + * Scope is TRANSIENT view state — which repos and people the board is + * narrowed to right now. Empty arrays mean everything. It lives in memory + * and in the URL hash (repos=/authors=/xauthors=), never in localStorage: + * the hash is the view state, and a second, invisible persistence resurrected + * months-old allow-lists on hash-less loads — a stale scope naming a hidden + * repo counted as an "explicit reveal" and kept its pulls on the board no + * matter how often the user hid it (the prod mute bug). Durable curation + * (hidden repos/people, rosters) belongs to settings, not scope. */ export interface Scope { repos: string[]; @@ -17,16 +21,16 @@ export interface Scope { notAuthors: string[]; } -const store = createPersistentStore('pd2.scope', { repos: [], authors: [], notAuthors: [] }); +const store = createMemoryStore({ repos: [], authors: [], notAuthors: [] }); -/** - * Apply a scope from a shared URL for this session WITHOUT persisting it — - * opening a teammate's link must not silently overwrite your saved board. - * The moment the user edits the scope themselves, useScope's setter saves - * as usual. - */ +// housekeeping for the persistence this store used to have: drop the old +// key so a future reader can't mistake it for live state +removeStorage('pd2.scope'); + +/** Apply a scope from the URL hash (load or hashchange). Same as setScope — + * kept as a named door because the hash is scope's one durable carrier. */ export function primeScope(next: Scope) { - store.prime(next); + store.set(next); } export function useScope(): [Scope, (next: Scope) => void] { diff --git a/frontend-v2/src/settings.ts b/frontend-v2/src/settings.ts index 5522ee1a..8a958d39 100644 --- a/frontend-v2/src/settings.ts +++ b/frontend-v2/src/settings.ts @@ -33,8 +33,9 @@ export interface Settings { * the urgency weight always follows the OPENED clock — how long a pull * has been open is the truth the board ranks by. */ ageDisplay: 'opened' | 'updated'; - /** per-repo override of the org baseline: 'hide' hides a shown repo, - * 'show' reveals an org-hidden one. Absent = follow the org default. */ + /** per-repo hides: 'hide' takes a repo off the board. 'show' is a relic + * of the retired org-level mute (it overrode the org default) — stored + * values still parse but mean the same as absent: shown. */ repoPrefs: Record; /** your default for other people's drafts: 'mine' hides them (your own * always show), 'all' shows everyone's */ diff --git a/frontend-v2/src/storage.ts b/frontend-v2/src/storage.ts index 23bdc5c6..fb4ae727 100644 --- a/frontend-v2/src/storage.ts +++ b/frontend-v2/src/storage.ts @@ -21,6 +21,14 @@ export function writeStorage(key: string, value: string): void { } } +export function removeStorage(key: string): void { + try { + localStorage.removeItem(key); + } catch { + // storage blocked: nothing was persisted to remove + } +} + /** * Wipe every persisted preference — all of our `pd2.` localStorage keys * (settings, scope, last-seen marker). The caller reloads so the in-memory @@ -44,6 +52,30 @@ export function clearStoredPrefs(): void { * board). Unknown fields in a stale saved blob fall back to their default, so * a new field never breaks an old blob. */ +/** + * createPersistentStore's in-memory twin: same get/set/subscribe/useValue + * surface, nothing written anywhere. For state whose durable carrier is the + * URL hash (scope) — a localStorage shadow copy of hash-carried state + * resurrects stale versions of it on hash-less loads. + */ +export function createMemoryStore(defaults: T) { + let value = { ...defaults }; + const listeners = new Set<() => void>(); + const subscribe = (fn: () => void) => { + listeners.add(fn); + return () => listeners.delete(fn); + }; + return { + get: () => value, + set(next: T) { + value = next; + for (const fn of listeners) fn(); + }, + subscribe, + useValue: () => useSyncExternalStore(subscribe, () => value), + }; +} + export function createPersistentStore(key: string, defaults: T) { const load = (): T => { try { diff --git a/frontend-v2/src/types.ts b/frontend-v2/src/types.ts index 2034d431..cd04d482 100644 --- a/frontend-v2/src/types.ts +++ b/frontend-v2/src/types.ts @@ -16,7 +16,8 @@ export interface RepoSpec { name: string; requiredStatuses?: string[]; ignoredStatuses?: string[]; - hideByDefault?: boolean; + /* the wire also carries a v1-era `hideByDefault` flag; the client + ignores it — repo hiding is per-user (see model/visibility.ts) */ } export interface Label { From d0a98a7ce2774c40f34dc10bcb7cb4621a209940 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Thu, 23 Jul 2026 18:11:41 -0700 Subject: [PATCH 327/381] Header stops eating clicks at narrow widths; badge gets headroom Three prod reports, one afternoon, two causes. The h1 wrapping the logo + wordmark floats over the tab row at z-[1]. Its box always spans the wordmark's full width - even below 2xl where the letters are invisible - so at narrow widths a ~150px transparent strip sat exactly over "Review" (sm+) or over the lens menu's label (phone), swallowing their clicks. That's both the "Review tab not clickable" and the "select only clickable on the icon" reports. Nothing in the h1 is interactive, so it takes pointer-events-none and clicks fall through to the controls beneath. Verified by hit-testing at 315px and 700px: elementFromPoint over the tab/menu label now returns the button itself. The My-work count badge rides 4px above its tab, and the tab strip is an overflow-x-auto scroll container, which also clips vertically - the badge's top was shaved off (prod screenshot). The strip gains py-1 compensated by -my-1: 4px of in-clip headroom, zero layout movement. Co-Authored-By: Claude Fable 5 --- frontend-v2/src/app.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index ebf8a065..a04a4a08 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -820,8 +820,12 @@ export function App() { clickable over the tab row's own box. */}
{/* the page's one h1 — heading navigation needs a root, and - every lane h2 needs a parent level */} -

+ every lane h2 needs a parent level. pointer-events-none: + nothing in it is interactive, and its box spans the hidden + wordmark's width — at narrow widths that invisible span sat + over the Review tab / lens menu at z-[1] and ate their + clicks (prod report) */} +

@@ -895,7 +899,11 @@ export function App() { Below sm the strip no longer fits at all and that swipe is undiscoverable on a phone, so it's hidden there in favor of LensMenu's dropdown twin. */} -
+ + set({ repoQueueCap: Number(v) })} + /> + + diff --git a/frontend-v2/src/components/filters/RepoFilter.tsx b/frontend-v2/src/components/filters/RepoFilter.tsx index 87c9fe91..01608cbe 100644 --- a/frontend-v2/src/components/filters/RepoFilter.tsx +++ b/frontend-v2/src/components/filters/RepoFilter.tsx @@ -1,9 +1,9 @@ import { useState } from 'react'; -import { ChevronRight } from 'lucide-react'; +import { ChevronRight, GripVertical } from 'lucide-react'; import { repoHidden } from '../../model/visibility'; import { shortRepo } from '../../format'; import type { Scope } from '../../prefs'; -import { setRepoPref, useSettings } from '../../settings'; +import { setRepoPref, setSettings, useSettings } from '../../settings'; import { Icon } from '../Icon'; import { Popover } from '../Popover'; import { ClearRow, EyeButton, FilterSearch, FilterTrigger, OnlyButton } from './shared'; @@ -15,9 +15,15 @@ import { ClearRow, EyeButton, FilterSearch, FilterTrigger, OnlyButton } from './ * your board right now) and hide (which repos are off your board, period) * live side by side on each row. Hiding is per-user only: the server * config's v1-era org-level mute is ignored — each user hides a repo once, - * their call. (Parked PRs and drafts used to ride along here as session - * toggles; they're board-level hiding, so they live in the filter bar's - * hidden-PR ledger now — see HiddenPanel.) + * their call. + * + * The list's ORDER is load-bearing: rows sort by settings.repoPriority — the + * same order the review queue renders its repo blocks in — and the grip + * handle reorders it (drag, or arrow keys on the focused grip). This panel + * is the one place the order changes; Settings keeps only the per-repo cap. + * (Parked PRs and drafts used to ride along here as session toggles; they're + * board-level hiding, so they live in the filter bar's hidden-PR ledger now — + * see HiddenPanel.) */ export function RepoFilter({ repos, @@ -39,11 +45,43 @@ export function RepoFilter({ }) { const settings = useSettings(); const prefs = settings.repoPrefs; + const priority = settings.repoPriority; const [repoQuery, setRepoQuery] = useState(''); + const [dragging, setDragging] = useState(null); + const [dropTarget, setDropTarget] = useState(null); - const shownRepos = repos.filter(r => !repoHidden(r.name, prefs)); + // the list shows YOUR order: priority-listed repos first, in that order + // (stable sort keeps the incoming count order among the unlisted tail) + const orderOf = (name: string) => { + const i = priority.indexOf(name); + return i === -1 ? Number.POSITIVE_INFINITY : i; + }; + const shownRepos = repos + .filter(r => !repoHidden(r.name, prefs)) + .sort((a, b) => orderOf(a.name) - orderOf(b.name)); const hiddenRepos = repos.filter(r => repoHidden(r.name, prefs)); + // any reorder writes the full shown order as the priority — after the + // first drag every visible repo is explicitly placed, which is exactly + // what the user just expressed + const reorder = (name: string, target: string) => { + if (name === target) return; + const order = shownRepos.map(r => r.name); + order.splice(order.indexOf(name), 1); + // inserting at the target's post-removal index lands before it when + // dragging up and after it when dragging down — the intuitive drop + order.splice(order.indexOf(target), 0, name); + setSettings({ repoPriority: order }); + }; + const nudge = (name: string, key: string) => { + const order = shownRepos.map(r => r.name); + const i = order.indexOf(name); + const j = key === 'ArrowUp' ? i - 1 : i + 1; + if (j < 0 || j >= order.length) return; + [order[i], order[j]] = [order[j], order[i]]; + setSettings({ repoPriority: order }); + }; + const commit = (next: string[], all: string[]) => setScope({ ...scope, repos: all.length && next.length === all.length ? [] : next }); const toggleScope = (name: string, all: string[]) => { @@ -69,7 +107,49 @@ export function RepoFilter({
{ + if (!dragging) return; + e.preventDefault(); + setDropTarget(name); + }} + onDrop={e => { + e.preventDefault(); + if (dragging) reorder(dragging, name); + setDragging(null); + setDropTarget(null); + }} + // an inset shadow, not a border: the drop indicator must not move + // the rows it's pointing between + style={ + dropTarget === name && dragging !== name + ? { boxShadow: 'inset 0 2px 0 0 var(--brand)' } + : undefined + } > + { + setDragging(name); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', name); + }} + onDragEnd={() => { + setDragging(null); + setDropTarget(null); + }} + onKeyDown={e => { + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; + e.preventDefault(); + nudge(name, e.key); + }} + > + +