Build Rank feature prioritization tool

This commit is contained in:
OpenClaw Bot
2026-05-21 20:03:56 +02:00
commit dec6a844d7
11 changed files with 1894 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
.env
.env.*
!.env.example
*.log
rank.log
.DS_Store
+8
View File
@@ -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"]
+58
View File
@@ -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}'
```
+892
View File
@@ -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"
}
}
}
+22
View File
@@ -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"
}
}
+259
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[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]) => `<div class="stat"><strong>${value}</strong><span>${label}</span></div>`).join('');
}
function renderMilestoneOptions() {
milestoneSelect.innerHTML = state.milestones.map(m => `<option value="${escapeHtml(m.id)}">${escapeHtml(m.name)}</option>`).join('');
}
function renderActivity() {
$('#activity').innerHTML = state.activity.slice(0, 8).map(item => `<span class="activity-item">${escapeHtml(short(item.message, 52))}</span>`).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('') || '<div class="empty">Drop something here</div>';
return `<article class="lane" data-milestone="${escapeHtml(milestone.id)}" style="--lane-color:${escapeHtml(milestone.color)}">
<div class="lane-head">
<div class="lane-title"><h2>${escapeHtml(milestone.name)}</h2><strong>${laneIdeas.length}</strong></div>
<p>${escapeHtml(milestone.description || milestone.horizon || '')}</p>
</div>
<div class="cards">${cards}</div>
</article>`;
}).join('');
bindDrag();
bindCards();
}
function cardHtml(idea) {
const tags = [`${sourceKind(idea)}`, idea.sourceName, ...(idea.labels || [])].filter(Boolean).slice(0, 5);
return `<article class="card" draggable="true" data-id="${escapeHtml(idea.id)}">
<div class="card-top"><h3>${escapeHtml(idea.title)}</h3><div class="score">${scoreOf(idea)}</div></div>
${idea.description ? `<p>${escapeHtml(short(idea.description))}</p>` : ''}
<div class="meta">${tags.map(t => `<span class="pill">${escapeHtml(t)}</span>`).join('')}</div>
<div class="metrics">
<span>I <b>${idea.impact}</b></span><span>E <b>${idea.effort}</b></span><span>C <b>${idea.confidence}</b></span><span>U <b>${idea.urgency}</b></span>
</div>
</article>`;
}
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 = `<div class="empty">Backend is grumpy: ${escapeHtml(error.message)}</div>`;
}
}
load();
+89
View File
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#050712" />
<title>Rank — Feature Priorities</title>
<link rel="stylesheet" href="/styles.css?v=rank-1" />
</head>
<body>
<div class="noise"></div>
<main class="shell">
<header class="hero">
<div>
<p class="eyebrow">rank.friborg.uk · feature triage</p>
<h1>Drop ideas. Score fast. Drag into reality.</h1>
<p class="subcopy">A sharp prioritization board for humans and agents. No ceremony, no rounded-corner startup soup.</p>
</div>
<div class="stats" id="stats"></div>
</header>
<section class="capture-panel" aria-label="Quick capture">
<form id="ideaForm" autocomplete="off">
<div class="capture-main">
<label for="title">Idea</label>
<input id="title" name="title" maxlength="180" placeholder="Press / then type the feature that keeps nagging at you" required />
</div>
<div class="capture-grid">
<label>Description<textarea id="description" name="description" rows="2" placeholder="Optional: what it does, why it matters, ugly constraints…"></textarea></label>
<label>Labels<input id="labels" name="labels" placeholder="scattermind, revenue, ux" /></label>
<label>Source<input id="sourceName" name="sourceName" placeholder="Jimmi, Rook, Iris…" /></label>
<label>Milestone<select id="milestoneSelect" name="milestoneId"></select></label>
</div>
<div class="score-row">
<label>Impact <input type="range" min="0" max="10" value="7" name="impact" /></label>
<label>Effort <input type="range" min="1" max="10" value="4" name="effort" /></label>
<label>Confidence <input type="range" min="0" max="10" value="6" name="confidence" /></label>
<label>Urgency <input type="range" min="0" max="10" value="5" name="urgency" /></label>
<button type="submit">Capture ↵</button>
</div>
</form>
</section>
<section class="toolbar">
<div class="tabs" id="filters">
<button data-filter="all" class="active">All</button>
<button data-filter="human">Human</button>
<button data-filter="agent">Agent</button>
<button data-filter="high">High score</button>
</div>
<div class="tools">
<input id="search" placeholder="Filter ideas" />
<button id="addMilestone">+ Milestone</button>
<button id="refresh">Refresh</button>
</div>
</section>
<section id="board" class="board" aria-label="Priority board"></section>
<aside class="detail" id="detail" aria-hidden="true">
<form id="detailForm">
<div class="detail-head">
<p class="eyebrow">selected idea</p>
<button type="button" id="closeDetail">×</button>
</div>
<input name="title" class="detail-title" />
<textarea name="description" rows="8" placeholder="Description"></textarea>
<div class="detail-sliders">
<label>Impact <input type="number" min="0" max="10" name="impact" /></label>
<label>Effort <input type="number" min="1" max="10" name="effort" /></label>
<label>Confidence <input type="number" min="0" max="10" name="confidence" /></label>
<label>Urgency <input type="number" min="0" max="10" name="urgency" /></label>
</div>
<label>Notes<textarea name="notes" rows="5" placeholder="Decision notes, objections, cut lines…"></textarea></label>
<div class="detail-actions">
<button type="submit">Save</button>
<button type="button" id="archiveIdea">Archive</button>
</div>
</form>
</aside>
<footer>
<div id="activity"></div>
<p>Keyboard: <kbd>/</kbd> capture · <kbd>Esc</kbd> close · drag cards between milestones.</p>
</footer>
</main>
<script src="/app.js?v=rank-1" type="module"></script>
</body>
</html>
+108
View File
@@ -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; }
}
+150
View File
@@ -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));
+25
View File
@@ -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));
+276
View File
@@ -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}`));