commit dec6a844d76e0cc3200dd2450ea750a5ace7a93b Author: OpenClaw Bot Date: Thu May 21 20:03:56 2026 +0200 Build Rank feature prioritization tool diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b5d5e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env +.env.* +!.env.example +*.log +rank.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..83860d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY package*.json ./ +RUN npm ci --omit=dev +COPY . . +EXPOSE 3045 +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f406b12 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Rank + +Interactive feature prioritization tool for `rank.friborg.uk`. + +## Product definition + +Rank is a fast intake and prioritization board for product ideas from Jimmi and agents. + +Core loop: + +`Capture idea → score impact/effort/confidence/urgency → drag into milestone → revisit top-ranked work` + +Chosen subdomain: `rank.friborg.uk` — short, memorable, and honest about the job. + +## UX principles + +- One-screen capture, no modal ceremony. +- Keyboard-first: `/` focuses capture, Enter saves. +- Plain sharp visual system: zero rounded corners, dark space/glass, high contrast. +- Milestones are customizable lanes, not a rigid roadmap prison. +- Agents can post ideas through the same API endpoint as the UI. + +## Architecture + +- Node/Express app on port `3045` +- Static SPA in `public/` +- Appwrite TablesDB persistence +- Docker deploy on Unraid +- Gitea remote repo +- Nginx Proxy Manager routes `rank.friborg.uk` → `192.168.30.100:3045` + +## Appwrite schema + +Database: `priority_rank` + +Tables: + +- `ideas` — title, description, source, sourceName, milestoneId, impact, effort, confidence, urgency, score, rank, labels, notes, archived +- `milestones` — name, description, horizon, color, position, active +- `activity` — small append-only UX feed + +## Commands + +```bash +npm run setup:appwrite +npm run check +PORT=3045 node server.js +npm run smoke +``` + +Agent idea post: + +```bash +curl -X POST https://rank.friborg.uk/api/ideas \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $RANK_AGENT_TOKEN" \ + -d '{"title":"Add public roadmap export","source":"agent","sourceName":"Rook","impact":8,"effort":3,"confidence":7,"urgency":5}' +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6aef598 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,892 @@ +{ + "name": "rank.friborg.uk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rank.friborg.uk", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "dotenv": "^17.4.2", + "express": "^5.2.1", + "node-appwrite": "^25.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "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/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-appwrite": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-25.0.0.tgz", + "integrity": "sha512-KpZ/3Ed8euz6r5CwjquElX3wRkNuiRuRQqjROiHK+feZ2ZX8HjjcF5IwrjTJYSNaYrmIwsZoex4L0ezzWjYWFg==", + "license": "BSD-3-Clause", + "dependencies": { + "json-bigint": "1.0.0", + "node-fetch-native-with-agent": "1.7.2" + } + }, + "node_modules/node-fetch-native-with-agent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz", + "integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c8f29f1 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "rank.friborg.uk", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node server.js", + "check": "node --check server.js && node --check scripts/setup-appwrite.mjs && node --check public/app.js", + "setup:appwrite": "node scripts/setup-appwrite.mjs", + "smoke": "node scripts/smoke.mjs" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "dotenv": "^17.4.2", + "express": "^5.2.1", + "node-appwrite": "^25.0.0" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..bcb469a --- /dev/null +++ b/public/app.js @@ -0,0 +1,259 @@ +const state = { + ideas: [], + milestones: [], + activity: [], + filter: 'all', + search: '', + selected: null, +}; + +const $ = (sel, root = document) => root.querySelector(sel); +const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); +const board = $('#board'); +const form = $('#ideaForm'); +const detail = $('#detail'); +const detailForm = $('#detailForm'); +const milestoneSelect = $('#milestoneSelect'); + +async function api(path, options = {}) { + const res = await fetch(path, { + headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, + ...options, + body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body, + }); + const text = await res.text(); + const data = text ? JSON.parse(text) : null; + if (!res.ok) throw new Error(data?.error || res.statusText); + return data; +} + +function scoreOf(idea) { return Number(idea.score || 0).toFixed(1); } +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>'"]/g, ch => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[ch])); +} +function short(text, len = 126) { + const value = String(text || '').trim(); + return value.length > len ? `${value.slice(0, len - 1)}…` : value; +} +function milestoneFor(id) { return state.milestones.find(m => m.id === id) || state.milestones[0]; } +function sourceKind(idea) { return /agent|rook|iris|eve|claude|gpt|bot/i.test(`${idea.source} ${idea.sourceName}`) ? 'agent' : 'human'; } +function toast(message) { + const el = document.createElement('div'); + el.className = 'toast'; + el.textContent = message; + document.body.append(el); + setTimeout(() => el.remove(), 2200); +} + +function filteredIdeas() { + const q = state.search.toLowerCase(); + return state.ideas.filter(idea => { + if (state.filter === 'human' && sourceKind(idea) !== 'human') return false; + if (state.filter === 'agent' && sourceKind(idea) !== 'agent') return false; + if (state.filter === 'high' && Number(idea.score) < 4) return false; + if (!q) return true; + return [idea.title, idea.description, idea.sourceName, ...(idea.labels || [])].join(' ').toLowerCase().includes(q); + }); +} + +function renderStats() { + const ideas = state.ideas; + const now = ideas.filter(i => i.milestoneId === 'now').length; + const agent = ideas.filter(i => sourceKind(i) === 'agent').length; + const top = ideas.slice().sort((a, b) => b.score - a.score)[0]; + $('#stats').innerHTML = [ + ['Ideas', ideas.length], + ['Now', now], + ['Agent drops', agent], + ['Top score', top ? scoreOf(top) : '—'], + ].map(([label, value]) => `
${value}${label}
`).join(''); +} + +function renderMilestoneOptions() { + milestoneSelect.innerHTML = state.milestones.map(m => ``).join(''); +} + +function renderActivity() { + $('#activity').innerHTML = state.activity.slice(0, 8).map(item => `${escapeHtml(short(item.message, 52))}`).join(''); +} + +function renderBoard() { + renderStats(); + renderMilestoneOptions(); + renderActivity(); + const ideas = filteredIdeas(); + board.innerHTML = state.milestones.map(milestone => { + const laneIdeas = ideas + .filter(idea => (idea.milestoneId || 'inbox') === milestone.id) + .sort((a, b) => (a.rank - b.rank) || (b.score - a.score)); + const cards = laneIdeas.map(cardHtml).join('') || '
Drop something here
'; + return `
+
+

${escapeHtml(milestone.name)}

${laneIdeas.length}
+

${escapeHtml(milestone.description || milestone.horizon || '')}

+
+
${cards}
+
`; + }).join(''); + bindDrag(); + bindCards(); +} + +function cardHtml(idea) { + const tags = [`${sourceKind(idea)}`, idea.sourceName, ...(idea.labels || [])].filter(Boolean).slice(0, 5); + return `
+

${escapeHtml(idea.title)}

${scoreOf(idea)}
+ ${idea.description ? `

${escapeHtml(short(idea.description))}

` : ''} +
${tags.map(t => `${escapeHtml(t)}`).join('')}
+
+ I ${idea.impact}E ${idea.effort}C ${idea.confidence}U ${idea.urgency} +
+
`; +} + +function bindCards() { + $$('.card').forEach(card => card.addEventListener('click', () => openDetail(card.dataset.id))); +} + +function bindDrag() { + $$('.card').forEach(card => { + card.addEventListener('dragstart', event => { + card.classList.add('dragging'); + event.dataTransfer.setData('text/plain', card.dataset.id); + }); + card.addEventListener('dragend', () => card.classList.remove('dragging')); + }); + $$('.lane').forEach(lane => { + lane.addEventListener('dragover', event => { event.preventDefault(); lane.classList.add('drag-over'); }); + lane.addEventListener('dragleave', () => lane.classList.remove('drag-over')); + lane.addEventListener('drop', async event => { + event.preventDefault(); + lane.classList.remove('drag-over'); + const id = event.dataTransfer.getData('text/plain'); + const idea = state.ideas.find(i => i.id === id); + const milestoneId = lane.dataset.milestone; + if (!idea || idea.milestoneId === milestoneId) return; + idea.milestoneId = milestoneId; + idea.rank = Date.now() % 100000; + renderBoard(); + try { + const updated = await api(`/api/ideas/${id}`, { method: 'PATCH', body: { milestoneId, rank: idea.rank } }); + replaceIdea(updated); + toast(`Moved to ${milestoneFor(milestoneId).name}`); + } catch (error) { toast(error.message); await load(); } + }); + }); +} + +function replaceIdea(idea) { + const idx = state.ideas.findIndex(i => i.id === idea.id); + if (idx >= 0) state.ideas[idx] = idea; + else state.ideas.unshift(idea); + state.ideas.sort((a, b) => b.score - a.score); +} + +function openDetail(id) { + const idea = state.ideas.find(i => i.id === id); + if (!idea) return; + state.selected = idea.id; + detailForm.title.value = idea.title; + detailForm.description.value = idea.description || ''; + detailForm.impact.value = idea.impact; + detailForm.effort.value = idea.effort; + detailForm.confidence.value = idea.confidence; + detailForm.urgency.value = idea.urgency; + detailForm.notes.value = idea.notes || ''; + detail.classList.add('open'); + detail.setAttribute('aria-hidden', 'false'); +} +function closeDetail() { + detail.classList.remove('open'); + detail.setAttribute('aria-hidden', 'true'); + state.selected = null; +} + +form.addEventListener('submit', async event => { + event.preventDefault(); + const fd = new FormData(form); + const payload = Object.fromEntries(fd.entries()); + payload.labels = String(payload.labels || '').split(',').map(s => s.trim()).filter(Boolean); + payload.source = payload.sourceName ? sourceKind({ sourceName: payload.sourceName }) : 'human'; + payload.status = payload.milestoneId === 'inbox' ? 'inbox' : 'planned'; + try { + const idea = await api('/api/ideas', { method: 'POST', body: payload }); + replaceIdea(idea); + form.reset(); + form.impact.value = 7; form.effort.value = 4; form.confidence.value = 6; form.urgency.value = 5; + renderBoard(); + toast('Captured'); + } catch (error) { toast(error.message); } +}); + +detailForm.addEventListener('submit', async event => { + event.preventDefault(); + if (!state.selected) return; + const payload = Object.fromEntries(new FormData(detailForm).entries()); + try { + const idea = await api(`/api/ideas/${state.selected}`, { method: 'PATCH', body: payload }); + replaceIdea(idea); + closeDetail(); + renderBoard(); + toast('Saved'); + } catch (error) { toast(error.message); } +}); + +$('#archiveIdea').addEventListener('click', async () => { + if (!state.selected) return; + try { + await api(`/api/ideas/${state.selected}`, { method: 'PATCH', body: { archived: true } }); + state.ideas = state.ideas.filter(i => i.id !== state.selected); + closeDetail(); + renderBoard(); + toast('Archived'); + } catch (error) { toast(error.message); } +}); + +$('#closeDetail').addEventListener('click', closeDetail); +$('#refresh').addEventListener('click', load); +$('#search').addEventListener('input', event => { state.search = event.target.value; renderBoard(); }); +$('#filters').addEventListener('click', event => { + const button = event.target.closest('button[data-filter]'); + if (!button) return; + state.filter = button.dataset.filter; + $$('#filters button').forEach(b => b.classList.toggle('active', b === button)); + renderBoard(); +}); +$('#addMilestone').addEventListener('click', async () => { + const name = prompt('Milestone name'); + if (!name) return; + const horizon = prompt('Horizon / timing', 'Custom') || ''; + const colors = ['#8cf7ff', '#f8ff73', '#a78bfa', '#6ee7b7', '#ff5e7a']; + try { + const milestone = await api('/api/milestones', { method: 'POST', body: { name, horizon, color: colors[state.milestones.length % colors.length], position: state.milestones.length * 10 } }); + state.milestones.push(milestone); + renderBoard(); + toast('Milestone added'); + } catch (error) { toast(error.message); } +}); + +document.addEventListener('keydown', event => { + if (event.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) { + event.preventDefault(); + $('#title').focus(); + } + if (event.key === 'Escape') closeDetail(); +}); + +async function load() { + try { + const data = await api('/api/bootstrap'); + state.ideas = data.ideas || []; + state.milestones = data.milestones || []; + state.activity = data.activity || []; + renderBoard(); + } catch (error) { + board.innerHTML = `
Backend is grumpy: ${escapeHtml(error.message)}
`; + } +} + +load(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..ff1df5f --- /dev/null +++ b/public/index.html @@ -0,0 +1,89 @@ + + + + + + + Rank — Feature Priorities + + + +
+
+
+
+

rank.friborg.uk · feature triage

+

Drop ideas. Score fast. Drag into reality.

+

A sharp prioritization board for humans and agents. No ceremony, no rounded-corner startup soup.

+
+
+
+ +
+
+
+ + +
+
+ + + + +
+
+ + + + + +
+
+
+ +
+
+ + + + +
+
+ + + +
+
+ +
+ + + +
+
+

Keyboard: / capture · Esc close · drag cards between milestones.

+
+
+ + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..e6cfe5a --- /dev/null +++ b/public/styles.css @@ -0,0 +1,108 @@ +:root { + color-scheme: dark; + --bg: #050712; + --panel: rgba(9, 14, 31, .74); + --panel-strong: rgba(13, 22, 45, .92); + --line: rgba(159, 231, 255, .22); + --line-hot: rgba(248, 255, 115, .55); + --text: #eff8ff; + --muted: #8fa4b8; + --cyan: #8cf7ff; + --yellow: #f8ff73; + --violet: #a78bfa; + --green: #6ee7b7; + --danger: #ff5e7a; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); } +body { + background: + radial-gradient(circle at 18% 8%, rgba(140, 247, 255, .22), transparent 31rem), + radial-gradient(circle at 76% 6%, rgba(167, 139, 250, .20), transparent 34rem), + linear-gradient(135deg, #050712 0%, #09111f 46%, #03040a 100%); + overflow-x: hidden; +} +button, input, textarea, select { font: inherit; border-radius: 0; } +button { cursor: pointer; color: var(--text); background: #101a31; border: 1px solid var(--line); text-transform: uppercase; letter-spacing: .08em; font-size: .78rem; font-weight: 800; transition: .16s ease; } +button:hover { border-color: var(--yellow); box-shadow: 0 0 28px rgba(248,255,115,.16); transform: translateY(-1px); } +input, textarea, select { width: 100%; background: rgba(1, 4, 11, .72); border: 1px solid var(--line); color: var(--text); padding: .8rem .9rem; outline: none; } +input:focus, textarea:focus, select:focus { border-color: var(--cyan); box-shadow: 0 0 0 1px rgba(140, 247, 255, .16), 0 0 30px rgba(140, 247, 255, .09); } +textarea { resize: vertical; } +label { display: grid; gap: .42rem; color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 800; } +kbd { border: 1px solid var(--line); padding: .08rem .32rem; background: rgba(255,255,255,.05); } +.noise { pointer-events: none; position: fixed; inset: 0; opacity: .12; mix-blend-mode: screen; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.04) 1px, transparent 1px); background-size: 32px 32px; mask-image: radial-gradient(circle at 50% 0%, black, transparent 80%); } +.shell { width: min(1720px, calc(100vw - 32px)); margin: 0 auto; padding: 34px 0 60px; position: relative; } +.hero { display: grid; grid-template-columns: 1fr auto; gap: 2rem; align-items: end; margin-bottom: 24px; } +.eyebrow { color: var(--cyan); text-transform: uppercase; letter-spacing: .22em; font-size: .72rem; font-weight: 900; margin: 0 0 .7rem; } +h1 { font-size: clamp(2.6rem, 5vw, 6.8rem); line-height: .86; letter-spacing: -.07em; margin: 0; max-width: 1050px; text-transform: uppercase; } +.subcopy { max-width: 760px; color: var(--muted); font-size: 1.02rem; line-height: 1.55; } +.stats { display: grid; grid-template-columns: repeat(2, minmax(120px, 1fr)); border: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(22px); min-width: 320px; } +.stat { padding: 1rem; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.stat:nth-child(2n) { border-right: 0; } +.stat strong { display: block; font-size: 2.1rem; letter-spacing: -.05em; } +.stat span { color: var(--muted); text-transform: uppercase; font-size: .68rem; letter-spacing: .12em; } +.capture-panel, .toolbar, .lane, .detail, footer { border: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(24px); box-shadow: 0 28px 120px rgba(0,0,0,.22); } +.capture-panel { padding: 16px; margin-bottom: 16px; position: sticky; top: 8px; z-index: 5; } +.capture-main { display: grid; grid-template-columns: 72px 1fr; align-items: center; gap: 12px; } +.capture-main input { font-size: 1.25rem; font-weight: 850; letter-spacing: -.02em; padding: 1rem; } +.capture-grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 12px; margin-top: 12px; } +.score-row { display: grid; grid-template-columns: repeat(4, 1fr) 160px; gap: 12px; align-items: end; margin-top: 12px; } +.score-row button { height: 49px; background: linear-gradient(90deg, rgba(140,247,255,.18), rgba(248,255,115,.2)); border-color: var(--line-hot); } +input[type="range"] { accent-color: var(--yellow); padding: 0; height: 32px; } +.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 10px; margin-bottom: 16px; } +.tabs, .tools { display: flex; gap: 8px; align-items: center; } +.tabs button.active { background: var(--yellow); color: #050712; border-color: var(--yellow); } +.tools input { min-width: 260px; } +.board { display: grid; grid-template-columns: repeat(4, minmax(280px, 1fr)); gap: 16px; align-items: start; } +.lane { min-height: 520px; position: relative; overflow: hidden; } +.lane::before { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: var(--lane-color, var(--cyan)); box-shadow: 0 0 32px var(--lane-color, var(--cyan)); } +.lane.drag-over { border-color: var(--yellow); box-shadow: 0 0 0 1px rgba(248,255,115,.3), 0 30px 120px rgba(248,255,115,.10); } +.lane-head { padding: 16px; border-bottom: 1px solid var(--line); display: grid; gap: .45rem; } +.lane-title { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; } +.lane-title h2 { margin: 0; text-transform: uppercase; letter-spacing: -.04em; font-size: 1.55rem; } +.lane-title strong { color: var(--lane-color, var(--cyan)); font-size: 1.8rem; } +.lane-head p { margin: 0; color: var(--muted); font-size: .85rem; min-height: 2.4em; } +.cards { display: grid; gap: 10px; padding: 12px; } +.card { border: 1px solid rgba(255,255,255,.12); background: linear-gradient(145deg, rgba(255,255,255,.07), rgba(255,255,255,.025)); padding: 12px; display: grid; gap: 10px; cursor: grab; position: relative; } +.card:hover { border-color: var(--cyan); background: linear-gradient(145deg, rgba(140,247,255,.11), rgba(255,255,255,.03)); } +.card:active { cursor: grabbing; } +.card.dragging { opacity: .35; } +.card-top { display: flex; justify-content: space-between; gap: 10px; align-items: start; } +.card h3 { margin: 0; font-size: 1rem; line-height: 1.15; letter-spacing: -.02em; } +.score { display: grid; place-items: center; min-width: 48px; height: 38px; border: 1px solid var(--line-hot); color: var(--yellow); font-weight: 950; background: rgba(248,255,115,.06); } +.card p { margin: 0; color: var(--muted); font-size: .84rem; line-height: 1.35; } +.meta { display: flex; flex-wrap: wrap; gap: 6px; } +.pill { border: 1px solid rgba(255,255,255,.13); padding: .2rem .38rem; color: var(--muted); font-size: .68rem; text-transform: uppercase; letter-spacing: .08em; } +.metrics { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid rgba(255,255,255,.1); padding-top: 8px; gap: 6px; } +.metrics span { color: var(--muted); font-size: .64rem; text-transform: uppercase; } +.metrics b { display: block; color: var(--text); font-size: .9rem; } +.detail { position: fixed; z-index: 20; top: 0; right: 0; height: 100vh; width: min(520px, 100vw); padding: 18px; transform: translateX(105%); transition: transform .18s ease; } +.detail.open { transform: translateX(0); } +.detail-head { display: flex; justify-content: space-between; align-items: center; } +.detail-head button { width: 40px; height: 40px; font-size: 1.4rem; } +.detail form { display: grid; gap: 12px; } +.detail-title { font-size: 1.55rem; font-weight: 900; letter-spacing: -.04em; } +.detail-sliders { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } +.detail-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +#archiveIdea { border-color: rgba(255,94,122,.5); color: #ffd5dd; } +footer { margin-top: 16px; padding: 14px; display: grid; grid-template-columns: 1fr auto; gap: 1rem; color: var(--muted); font-size: .82rem; } +#activity { display: flex; gap: 8px; flex-wrap: wrap; } +.activity-item { border: 1px solid rgba(255,255,255,.1); padding: .32rem .48rem; } +.empty { border: 1px dashed rgba(255,255,255,.14); color: var(--muted); padding: 1.2rem; text-align: center; } +.toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); z-index: 50; background: #f8ff73; color: #050712; padding: .8rem 1rem; border: 1px solid #fff; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; } +@media (max-width: 1180px) { + .hero { grid-template-columns: 1fr; } + .stats { min-width: 0; } + .board { grid-template-columns: repeat(2, minmax(280px, 1fr)); } + .capture-grid, .score-row { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 720px) { + .shell { width: min(100vw - 18px, 100%); padding-top: 16px; } + .board, .capture-grid, .score-row, .capture-main, footer { grid-template-columns: 1fr; } + .toolbar { align-items: stretch; flex-direction: column; } + .tabs, .tools { width: 100%; overflow-x: auto; } + .tools input { min-width: 180px; } + .capture-panel { position: relative; top: auto; } +} diff --git a/scripts/setup-appwrite.mjs b/scripts/setup-appwrite.mjs new file mode 100644 index 0000000..b8e5afa --- /dev/null +++ b/scripts/setup-appwrite.mjs @@ -0,0 +1,150 @@ +import 'dotenv/config'; +import { Client, TablesDB, ID, Query, TablesDBIndexType } from 'node-appwrite'; + +const endpoint = process.env.APPWRITE_ENDPOINT || process.env.APPWRITE_SELF_HOSTED_URL || process.env.APPWRITE_LOCAL_ENDPOINT; +const projectId = process.env.APPWRITE_PROJECT_ID || process.env.APPWRITE_SELF_HOSTED_PROJECT_ID || process.env.APPWRITE_LOCAL_PROJECT_ID; +const apiKey = process.env.APPWRITE_API_KEY || process.env.APPWRITE_SELF_HOSTED_API_KEY || process.env.APPWRITE_LOCAL_API_KEY; +const databaseId = process.env.RANK_APPWRITE_DATABASE_ID || process.env.APPWRITE_DATABASE_ID || 'priority_rank'; +const ideasTableId = process.env.RANK_IDEAS_TABLE_ID || 'ideas'; +const milestonesTableId = process.env.RANK_MILESTONES_TABLE_ID || 'milestones'; +const activityTableId = process.env.RANK_ACTIVITY_TABLE_ID || 'activity'; + +if (!endpoint || !projectId || !apiKey) throw new Error('Missing Appwrite endpoint/project/key env'); + +const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey); +const tables = new TablesDB(client); + +async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } +function isNotFound(error) { return error?.code === 404 || /not found/i.test(error?.message || ''); } +function isConflict(error) { return error?.code === 409 || /already exists|conflict/i.test(error?.message || ''); } + +async function ensureDatabase() { + try { + await tables.get({ databaseId }); + console.log(`database exists: ${databaseId}`); + } catch (error) { + if (!isNotFound(error)) throw error; + await tables.create({ databaseId, name: 'Priority Rank', enabled: true }); + console.log(`database created: ${databaseId}`); + } +} + +async function ensureTable(tableId, name) { + try { + await tables.getTable({ databaseId, tableId }); + console.log(`table exists: ${tableId}`); + } catch (error) { + if (!isNotFound(error)) throw error; + await tables.createTable({ databaseId, tableId, name, rowSecurity: false, enabled: true }); + console.log(`table created: ${tableId}`); + await waitForTable(tableId); + } +} + +async function waitForTable(tableId) { + for (let i = 0; i < 30; i++) { + const table = await tables.getTable({ databaseId, tableId }); + if (!table.status || table.status === 'available' || table.enabled) return table; + await sleep(1000); + } +} + +async function waitForColumn(tableId, key) { + for (let i = 0; i < 45; i++) { + const column = await tables.getColumn({ databaseId, tableId, key }); + if (!column.status || column.status === 'available') return column; + if (column.status === 'failed') throw new Error(`Column ${tableId}.${key} failed`); + await sleep(1000); + } +} + +async function ensureColumn(tableId, key, create) { + try { + await tables.getColumn({ databaseId, tableId, key }); + return console.log(`column exists: ${tableId}.${key}`); + } catch (error) { + if (!isNotFound(error)) throw error; + } + try { + await create(); + console.log(`column created: ${tableId}.${key}`); + } catch (error) { + if (!isConflict(error)) throw error; + } + await waitForColumn(tableId, key); +} + +async function ensureIndex(tableId, key, type, columns, orders = undefined) { + try { + await tables.getIndex({ databaseId, tableId, key }); + return console.log(`index exists: ${tableId}.${key}`); + } catch (error) { + if (!isNotFound(error)) throw error; + } + try { + await tables.createIndex({ databaseId, tableId, key, type, columns, orders }); + console.log(`index created: ${tableId}.${key}`); + } catch (error) { + if (!isConflict(error)) throw error; + } +} + +const varchar = (tableId, key, size, required = false, xdefault = undefined) => ensureColumn(tableId, key, () => tables.createVarcharColumn({ databaseId, tableId, key, size, required, xdefault })); +const text = (tableId, key, required = false, xdefault = undefined) => ensureColumn(tableId, key, () => tables.createTextColumn({ databaseId, tableId, key, required, xdefault })); +const integer = (tableId, key, required = false, min = undefined, max = undefined, xdefault = undefined) => ensureColumn(tableId, key, () => tables.createIntegerColumn({ databaseId, tableId, key, required, min, max, xdefault })); +const floatCol = (tableId, key, required = false, min = undefined, max = undefined, xdefault = undefined) => ensureColumn(tableId, key, () => tables.createFloatColumn({ databaseId, tableId, key, required, min, max, xdefault })); +const bool = (tableId, key, required = false, xdefault = undefined) => ensureColumn(tableId, key, () => tables.createBooleanColumn({ databaseId, tableId, key, required, xdefault })); + +async function seedMilestones() { + const existing = await tables.listRows({ databaseId, tableId: milestonesTableId, queries: [Query.limit(1)] }); + const rows = existing.rows || existing.documents || []; + if (rows.length) return console.log('milestones already seeded'); + const seed = [ + { name: 'Inbox', description: 'Raw captures waiting for judgement.', horizon: 'Unsorted', color: '#8cf7ff', position: 0, active: true }, + { name: 'Now', description: 'Highest leverage work. Do not let this lane become a landfill.', horizon: 'This sprint', color: '#f8ff73', position: 10, active: true }, + { name: 'Next', description: 'Strong ideas after the current push.', horizon: 'Soon', color: '#a78bfa', position: 20, active: true }, + { name: 'Later', description: 'Useful but not urgent.', horizon: 'Backlog', color: '#6ee7b7', position: 30, active: true }, + ]; + for (const row of seed) await tables.createRow({ databaseId, tableId: milestonesTableId, rowId: row.name.toLowerCase(), data: row }); + console.log('seeded milestones'); +} + +await ensureDatabase(); +await ensureTable(ideasTableId, 'Ideas'); +await ensureTable(milestonesTableId, 'Milestones'); +await ensureTable(activityTableId, 'Activity'); + +await varchar(ideasTableId, 'title', 180, true); +await text(ideasTableId, 'description', false); +await varchar(ideasTableId, 'source', 40, false, 'human'); +await varchar(ideasTableId, 'sourceName', 80, false); +await varchar(ideasTableId, 'status', 40, false, 'inbox'); +await varchar(ideasTableId, 'milestoneId', 64, false, 'inbox'); +await integer(ideasTableId, 'impact', false, 0, 10, 5); +await integer(ideasTableId, 'effort', false, 1, 10, 5); +await integer(ideasTableId, 'confidence', false, 0, 10, 6); +await integer(ideasTableId, 'urgency', false, 0, 10, 5); +await floatCol(ideasTableId, 'score', false, 0, 999, 0); +await integer(ideasTableId, 'rank', false, -100000, 100000, 0); +await varchar(ideasTableId, 'labels', 768, false, '[]'); +await text(ideasTableId, 'notes', false); +await bool(ideasTableId, 'archived', false, false); + +await varchar(milestonesTableId, 'name', 80, true); +await text(milestonesTableId, 'description', false); +await varchar(milestonesTableId, 'horizon', 80, false); +await varchar(milestonesTableId, 'color', 24, false, '#8cf7ff'); +await integer(milestonesTableId, 'position', false, -10000, 10000, 0); +await bool(milestonesTableId, 'active', false, true); + +await varchar(activityTableId, 'type', 80, true); +await varchar(activityTableId, 'message', 300, true); +await varchar(activityTableId, 'ideaId', 64, false); +await varchar(activityTableId, 'meta', 800, false); + +await ensureIndex(ideasTableId, 'score_rank', TablesDBIndexType.Key, ['score', 'rank'], ['DESC', 'ASC']).catch(e => console.warn('index score_rank skipped:', e.message)); +await ensureIndex(ideasTableId, 'milestone_rank', TablesDBIndexType.Key, ['milestoneId', 'rank'], ['ASC', 'ASC']).catch(e => console.warn('index milestone_rank skipped:', e.message)); +await ensureIndex(milestonesTableId, 'position', TablesDBIndexType.Key, ['position'], ['ASC']).catch(e => console.warn('index milestone position skipped:', e.message)); + +await seedMilestones(); +console.log(JSON.stringify({ ok: true, endpoint, projectId, databaseId, ideasTableId, milestonesTableId, activityTableId }, null, 2)); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..6fba242 --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; + +const base = process.env.RANK_BASE_URL || `http://127.0.0.1:${process.env.PORT || 3045}`; +const token = process.env.RANK_AGENT_TOKEN || process.env.PRIORITY_AGENT_TOKEN || ''; +const headers = { 'Content-Type': 'application/json' }; +if (token) headers.Authorization = `Bearer ${token}`; + +async function req(path, options = {}) { + const res = await fetch(`${base}${path}`, { headers: { ...headers, ...(options.headers || {}) }, ...options, body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body }); + const text = await res.text(); + const data = text ? JSON.parse(text) : null; + if (!res.ok) throw new Error(`${res.status} ${path}: ${data?.error || text}`); + return data; +} + +const health = await req('/api/health'); +assert.equal(health.ok, true, 'health ok'); +const created = await req('/api/ideas', { method: 'POST', body: { title: `Smoke test ${new Date().toISOString()}`, description: 'Automated persistence check. Safe to archive.', source: 'agent', sourceName: 'smoke', labels: ['smoke'], impact: 3, effort: 1, confidence: 8, urgency: 1 } }); +assert.ok(created.id, 'created id'); +const updated = await req(`/api/ideas/${created.id}`, { method: 'PATCH', body: { milestoneId: 'later', notes: 'Verified create + update path.' } }); +assert.equal(updated.milestoneId, 'later'); +await req(`/api/ideas/${created.id}`, { method: 'PATCH', body: { archived: true } }); +const boot = await req('/api/bootstrap'); +assert.ok(Array.isArray(boot.ideas), 'ideas list'); +console.log(JSON.stringify({ ok: true, created: created.id, archived: true, count: boot.ideas.length }, null, 2)); diff --git a/server.js b/server.js new file mode 100644 index 0000000..01e5c39 --- /dev/null +++ b/server.js @@ -0,0 +1,276 @@ +import 'dotenv/config'; +import express from 'express'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import crypto from 'node:crypto'; +import { Client, TablesDB, ID, Query } from 'node-appwrite'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PORT = Number(process.env.PORT || 3045); +const endpoint = process.env.APPWRITE_ENDPOINT || process.env.APPWRITE_LOCAL_ENDPOINT || process.env.APPWRITE_SELF_HOSTED_URL; +const projectId = process.env.APPWRITE_PROJECT_ID || process.env.APPWRITE_LOCAL_PROJECT_ID || process.env.APPWRITE_SELF_HOSTED_PROJECT_ID; +const apiKey = process.env.APPWRITE_API_KEY || process.env.APPWRITE_LOCAL_API_KEY || process.env.APPWRITE_SELF_HOSTED_API_KEY; +const databaseId = process.env.RANK_APPWRITE_DATABASE_ID || process.env.APPWRITE_DATABASE_ID || 'priority_rank'; +const ideasTableId = process.env.RANK_IDEAS_TABLE_ID || 'ideas'; +const milestonesTableId = process.env.RANK_MILESTONES_TABLE_ID || 'milestones'; +const activityTableId = process.env.RANK_ACTIVITY_TABLE_ID || 'activity'; +const agentToken = process.env.RANK_AGENT_TOKEN || process.env.PRIORITY_AGENT_TOKEN || ''; +const appVersion = process.env.APP_VERSION || 'rank-local'; + +if (!endpoint || !projectId || !apiKey) { + console.warn('[rank] Missing Appwrite configuration; /api/health will report degraded.'); +} + +const client = new Client(); +if (endpoint) client.setEndpoint(endpoint); +if (projectId) client.setProject(projectId); +if (apiKey) client.setKey(apiKey); +const tables = new TablesDB(client); +const app = express(); + +app.use(express.json({ limit: '256kb' })); +app.use(express.static(path.join(__dirname, 'public'), { + etag: true, + maxAge: process.env.NODE_ENV === 'production' ? '10m' : 0, +})); + +function clampInt(value, fallback, min = 0, max = 10) { + const n = Number.parseInt(value, 10); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, n)); +} + +function cleanText(value, max = 1000) { + return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max); +} + +function cleanMultiline(value, max = 6000) { + return String(value ?? '').replace(/\r\n/g, '\n').trim().slice(0, max); +} + +function scoreIdea({ impact, effort, confidence, urgency }) { + const i = clampInt(impact, 5); + const e = clampInt(effort, 5, 1, 10); + const c = clampInt(confidence, 5); + const u = clampInt(urgency, 5); + return Number((((i * 2.4) + (c * 1.2) + (u * 1.4)) / Math.max(1, e)).toFixed(2)); +} + +function publicIdea(row) { + return { + id: row.$id, + createdAt: row.$createdAt, + updatedAt: row.$updatedAt, + title: row.title, + description: row.description || '', + source: row.source || 'human', + sourceName: row.sourceName || '', + status: row.status || 'inbox', + milestoneId: row.milestoneId || 'inbox', + impact: row.impact ?? 5, + effort: row.effort ?? 5, + confidence: row.confidence ?? 5, + urgency: row.urgency ?? 5, + score: row.score ?? 0, + rank: row.rank ?? 0, + labels: parseList(row.labels), + notes: row.notes || '', + archived: Boolean(row.archived), + }; +} + +function publicMilestone(row) { + return { + id: row.$id, + createdAt: row.$createdAt, + updatedAt: row.$updatedAt, + name: row.name, + description: row.description || '', + horizon: row.horizon || '', + color: row.color || '#8cf7ff', + position: row.position ?? 0, + active: row.active !== false, + }; +} + +function parseList(value) { + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.map(String).slice(0, 12) : []; + } catch { + return String(value).split(',').map(s => s.trim()).filter(Boolean).slice(0, 12); + } +} + +function encodeList(value) { + if (Array.isArray(value)) return JSON.stringify(value.map(v => cleanText(v, 32)).filter(Boolean).slice(0, 12)); + return JSON.stringify(parseList(value)); +} + +function rowsFrom(result) { + const rows = result?.rows || result?.documents; + if (!Array.isArray(rows)) throw new Error('Appwrite returned an invalid table response'); + return rows; +} + +function assertRow(row) { + if (!row?.$id) throw new Error('Appwrite returned an invalid row response'); + return row; +} + +function requireAgent(req, res, next) { + if (!agentToken) return next(); + const header = req.get('authorization') || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : req.get('x-rank-token'); + const tokenBuffer = Buffer.from(token || ''); + const expectedBuffer = Buffer.from(agentToken); + if (tokenBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(tokenBuffer, expectedBuffer)) return next(); + return res.status(401).json({ error: 'agent token required' }); +} + +async function logActivity(type, message, ideaId = '', meta = '') { + try { + await tables.createRow({ + databaseId, + tableId: activityTableId, + rowId: ID.unique(), + data: { type, message: cleanText(message, 300), ideaId, meta: cleanText(meta, 800) }, + }); + } catch (error) { + console.warn('[rank] activity log failed', error.message); + } +} + +app.get('/api/health', async (_req, res) => { + const health = { ok: false, app: 'rank', version: appVersion, appwriteConfigured: Boolean(endpoint && projectId && apiKey), appwriteReachable: false, tableReachable: false }; + try { + if (health.appwriteConfigured) { + const probe = await tables.listRows({ databaseId, tableId: ideasTableId, queries: [Query.limit(1)] }); + rowsFrom(probe); + health.appwriteReachable = true; + health.tableReachable = true; + health.ok = true; + } + } catch (error) { + health.error = error.message; + } + res.status(health.ok ? 200 : 503).json(health); +}); + +app.get('/api/bootstrap', async (_req, res) => { + const [ideas, milestones, activity] = await Promise.all([ + tables.listRows({ databaseId, tableId: ideasTableId, queries: [Query.equal('archived', false), Query.orderDesc('score'), Query.orderAsc('rank'), Query.limit(100)] }), + tables.listRows({ databaseId, tableId: milestonesTableId, queries: [Query.equal('active', true), Query.orderAsc('position'), Query.limit(50)] }), + tables.listRows({ databaseId, tableId: activityTableId, queries: [Query.orderDesc('$createdAt'), Query.limit(18)] }).catch(() => ({ rows: [], documents: [] })), + ]); + res.json({ + version: appVersion, + ideas: rowsFrom(ideas).map(publicIdea), + milestones: rowsFrom(milestones).map(publicMilestone), + activity: rowsFrom(activity).map(row => ({ id: row.$id, createdAt: row.$createdAt, type: row.type, message: row.message, ideaId: row.ideaId || '' })), + scoring: '((impact×2.4)+(confidence×1.2)+(urgency×1.4))/effort', + }); +}); + +app.post('/api/ideas', requireAgent, async (req, res) => { + const title = cleanText(req.body.title, 180); + if (!title) return res.status(400).json({ error: 'title is required' }); + const data = { + title, + description: cleanMultiline(req.body.description, 5000), + source: cleanText(req.body.source || 'human', 40), + sourceName: cleanText(req.body.sourceName || req.body.agent || '', 80), + status: cleanText(req.body.status || 'inbox', 40), + milestoneId: cleanText(req.body.milestoneId || 'inbox', 64), + impact: clampInt(req.body.impact, 5), + effort: clampInt(req.body.effort, 5, 1, 10), + confidence: clampInt(req.body.confidence, 6), + urgency: clampInt(req.body.urgency, 5), + rank: clampInt(req.body.rank, 0, -100000, 100000), + labels: encodeList(req.body.labels), + notes: cleanMultiline(req.body.notes, 4000), + archived: false, + }; + data.score = scoreIdea(data); + const row = assertRow(await tables.createRow({ databaseId, tableId: ideasTableId, rowId: ID.unique(), data })); + await logActivity('idea.created', `Captured “${title}”`, row.$id, data.source); + res.status(201).json(publicIdea(row)); +}); + +app.patch('/api/ideas/:id', requireAgent, async (req, res) => { + const allowed = ['title', 'description', 'source', 'sourceName', 'status', 'milestoneId', 'impact', 'effort', 'confidence', 'urgency', 'rank', 'labels', 'notes', 'archived']; + const data = {}; + for (const key of allowed) { + if (!(key in req.body)) continue; + if (['impact', 'effort', 'confidence', 'urgency', 'rank'].includes(key)) data[key] = clampInt(req.body[key], key === 'effort' ? 5 : 0, key === 'effort' ? 1 : -100000, key === 'rank' ? 100000 : 10); + else if (key === 'description' || key === 'notes') data[key] = cleanMultiline(req.body[key], key === 'description' ? 5000 : 4000); + else if (key === 'labels') data[key] = encodeList(req.body[key]); + else if (key === 'archived') data[key] = Boolean(req.body[key]); + else data[key] = cleanText(req.body[key], key === 'title' ? 180 : 80); + } + if (['impact', 'effort', 'confidence', 'urgency'].some(k => k in data)) { + const current = assertRow(await tables.getRow({ databaseId, tableId: ideasTableId, rowId: req.params.id })); + data.score = scoreIdea({ ...current, ...data }); + } + const row = assertRow(await tables.updateRow({ databaseId, tableId: ideasTableId, rowId: req.params.id, data })); + await logActivity('idea.updated', `Updated “${row.title}”`, row.$id, Object.keys(data).join(',')); + res.json(publicIdea(row)); +}); + +app.post('/api/milestones', requireAgent, async (req, res) => { + const name = cleanText(req.body.name, 80); + if (!name) return res.status(400).json({ error: 'name is required' }); + const data = { + name, + description: cleanMultiline(req.body.description, 1000), + horizon: cleanText(req.body.horizon || '', 80), + color: cleanText(req.body.color || '#8cf7ff', 24), + position: clampInt(req.body.position, 0, -10000, 10000), + active: req.body.active !== false, + }; + const row = assertRow(await tables.createRow({ databaseId, tableId: milestonesTableId, rowId: ID.unique(), data })); + await logActivity('milestone.created', `Added milestone “${name}”`, '', row.$id); + res.status(201).json(publicMilestone(row)); +}); + +app.patch('/api/milestones/:id', requireAgent, async (req, res) => { + const data = {}; + for (const key of ['name', 'description', 'horizon', 'color', 'position', 'active']) { + if (!(key in req.body)) continue; + if (key === 'position') data[key] = clampInt(req.body[key], 0, -10000, 10000); + else if (key === 'active') data[key] = Boolean(req.body[key]); + else if (key === 'description') data[key] = cleanMultiline(req.body[key], 1000); + else data[key] = cleanText(req.body[key], key === 'name' ? 80 : 120); + } + const row = assertRow(await tables.updateRow({ databaseId, tableId: milestonesTableId, rowId: req.params.id, data })); + await logActivity('milestone.updated', `Updated milestone “${row.name}”`, '', row.$id); + res.json(publicMilestone(row)); +}); + +app.post('/api/reorder', requireAgent, async (req, res) => { + const updates = Array.isArray(req.body.updates) ? req.body.updates.slice(0, 100) : []; + const changed = []; + for (const item of updates) { + if (!item?.id) continue; + const data = {}; + if ('rank' in item) data.rank = clampInt(item.rank, 0, -100000, 100000); + if ('milestoneId' in item) data.milestoneId = cleanText(item.milestoneId, 64); + if ('status' in item) data.status = cleanText(item.status, 40); + if (Object.keys(data).length) { + const row = assertRow(await tables.updateRow({ databaseId, tableId: ideasTableId, rowId: item.id, data })); + changed.push(publicIdea(row)); + } + } + if (changed.length) await logActivity('ideas.reordered', `Re-ranked ${changed.length} item${changed.length === 1 ? '' : 's'}`); + res.json({ changed }); +}); + +app.get(/.*/, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html'))); + +app.use((error, _req, res, _next) => { + console.error('[rank]', error); + res.status(error.code && error.code >= 400 && error.code < 600 ? error.code : 500).json({ error: error.message || 'Internal error' }); +}); + +app.listen(PORT, () => console.log(`[rank] ${appVersion} listening on :${PORT}`));