Initial commit: personal task planner with categories, projects, and tiered assignment

Personal-first lists/tasks/reminders/recurrence, plus an admin-editable status
pipeline, shared categories, project containers, and a three-tier permission
model for cross-user work: assign (hand off your own tasks), reallocate
(dispatcher — move anyone's tasks), admin (reserved, full manage-all).
This commit is contained in:
jtricerolph 2026-09-17 08:07:00 +00:00
commit d8dc4e2b81
54 changed files with 10908 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
.env
uploads/
*.log

8
backend/Dockerfile Normal file
View file

@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev
COPY src ./src
RUN mkdir -p /app/uploads
EXPOSE 3001
CMD ["node", "src/index.js"]

902
backend/package-lock.json generated Normal file
View file

@ -0,0 +1,902 @@
{
"name": "hnf-tasks-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hnf-tasks-backend",
"version": "1.0.0",
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"nodemailer": "^6.9.16",
"pg": "^8.13.1",
"web-push": "^3.6.7"
}
},
"node_modules/@fastify/ajv-compiler": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
"integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.11.0",
"ajv-formats": "^2.1.1",
"fast-uri": "^2.0.0"
}
},
"node_modules/@fastify/cookie": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-9.4.0.tgz",
"integrity": "sha512-Th+pt3kEkh4MQD/Q2q1bMuJIB5NX/D5SwSpOKu3G/tjoGbwfpurIMJsWSPS0SJJ4eyjtmQ8OipDQspf8RbUOlg==",
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.1.0",
"fastify-plugin": "^4.0.0"
}
},
"node_modules/@fastify/cors": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz",
"integrity": "sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==",
"license": "MIT",
"dependencies": {
"fastify-plugin": "^4.0.0",
"mnemonist": "0.39.6"
}
},
"node_modules/@fastify/error": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz",
"integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==",
"license": "MIT"
},
"node_modules/@fastify/fast-json-stringify-compiler": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz",
"integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==",
"license": "MIT",
"dependencies": {
"fast-json-stringify": "^5.7.0"
}
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz",
"integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv/node_modules/fast-uri": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/avvio": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz",
"integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==",
"license": "MIT",
"dependencies": {
"@fastify/error": "^3.3.0",
"fastq": "^1.17.1"
}
},
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"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/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/fast-content-type-parse": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
"integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==",
"license": "MIT"
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-json-stringify": {
"version": "5.16.1",
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz",
"integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==",
"license": "MIT",
"dependencies": {
"@fastify/merge-json-schemas": "^0.1.0",
"ajv": "^8.10.0",
"ajv-formats": "^3.0.1",
"fast-deep-equal": "^3.1.3",
"fast-uri": "^2.1.0",
"json-schema-ref-resolver": "^1.0.1",
"rfdc": "^1.2.0"
}
},
"node_modules/fast-json-stringify/node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
"license": "MIT",
"dependencies": {
"fast-decode-uri-component": "^1.0.1"
}
},
"node_modules/fast-uri": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.7.tgz",
"integrity": "sha512-b8mggfg2R+m2OAb3Z+2HcJxUgU+2cY1JQondBDyXLdrEKNPxd96MvnB5yWKZdupsuX6GdBaAZHZo9msHX/Bb7A==",
"license": "MIT"
},
"node_modules/fastify": {
"version": "4.29.1",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz",
"integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/ajv-compiler": "^3.5.0",
"@fastify/error": "^3.4.0",
"@fastify/fast-json-stringify-compiler": "^4.3.0",
"abstract-logging": "^2.0.1",
"avvio": "^8.3.0",
"fast-content-type-parse": "^1.1.0",
"fast-json-stringify": "^5.8.0",
"find-my-way": "^8.0.0",
"light-my-request": "^5.11.0",
"pino": "^9.0.0",
"process-warning": "^3.0.0",
"proxy-addr": "^2.0.7",
"rfdc": "^1.3.0",
"secure-json-parse": "^2.7.0",
"semver": "^7.5.4",
"toad-cache": "^3.3.0"
}
},
"node_modules/fastify-plugin": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz",
"integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==",
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
"integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/find-my-way": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
"integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-querystring": "^1.0.0",
"safe-regex2": "^3.1.0"
},
"engines": {
"node": ">=14"
}
},
"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/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"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/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/json-schema-ref-resolver": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
"integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/light-my-request": {
"version": "5.14.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
"integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==",
"license": "BSD-3-Clause",
"dependencies": {
"cookie": "^0.7.0",
"process-warning": "^3.0.0",
"set-cookie-parser": "^2.4.1"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mnemonist": {
"version": "0.39.6",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
"integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.1"
}
},
"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/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.16.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/pino/node_modules/process-warning": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process-warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz",
"integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ret": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz",
"integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
"integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==",
"license": "MIT",
"dependencies": {
"ret": "~0.4.0"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"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/secure-json-parse": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/thread-stream": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz",
"integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
}
},
"node_modules/toad-cache": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz",
"integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

18
backend/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "hnf-tasks-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"nodemailer": "^6.9.16",
"pg": "^8.13.1",
"web-push": "^3.6.7"
}
}

57
backend/src/auth.js Normal file
View file

@ -0,0 +1,57 @@
import { jwtVerify } from 'jose'
import { isOnsite } from './ip-check.js'
const APP_SLUG = process.env.APP_SLUG || 'tasks'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
export async function requireAuth(request, reply) {
const token = request.cookies?.hnf_session
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
let payload
try {
const { payload: p } = await jwtVerify(token, secret)
payload = p
} catch {
return reply.status(401).send({ error: 'Invalid session' })
}
if (!payload.apps?.includes(APP_SLUG)) {
return reply.status(403).send({ error: 'No permission for this app' })
}
if (!payload.offsite_allowed) {
const clientIP = request.headers['x-real-ip'] || request.ip
if (!(await isOnsite(clientIP))) {
return reply.status(403).send({ error: 'Access restricted to site network' })
}
}
const prefix = `${APP_SLUG}:`
let caps
if (Array.isArray(payload.caps)) {
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
} else {
// Legacy token — grant basic use until re-login
caps = ['use']
}
request.user = {
email: payload.sub,
name: payload.name,
is_admin: payload.is_admin ?? false,
caps,
}
}
export function hasCap(request, cap) {
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
}
export function requireCap(cap) {
return async (request, reply) => {
if (!hasCap(request, cap)) {
return reply.status(403).send({ error: `Missing capability: ${cap}` })
}
}
}

192
backend/src/db.js Normal file
View file

@ -0,0 +1,192 @@
import pg from 'pg'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function initDb() {
await pool.query(`
-- App-wide key/value config (notification defaults, VAPID keys via lib/push.js).
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- A user's containers: "Today", "Handover", "Waiting on", "Projects"…
-- owner_id is the owner's email (the identity the auth service issues).
CREATE TABLE IF NOT EXISTS lists (
id SERIAL PRIMARY KEY,
owner_id TEXT NOT NULL,
name TEXT NOT NULL,
colour TEXT NOT NULL DEFAULT '#c9a84c',
icon TEXT NOT NULL DEFAULT 'ListChecks',
sort_order INT NOT NULL DEFAULT 0,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS lists_owner_idx ON lists (owner_id, sort_order);
-- Shared, admin-editable status pipeline (replaces a hardcoded todo/doing/done
-- enum). is_done drives completion/recurrence logic; is_hold just flags a
-- "parked" column for the board — it doesn't otherwise change behaviour.
CREATE TABLE IF NOT EXISTS statuses (
id SERIAL PRIMARY KEY,
label TEXT NOT NULL,
value TEXT NOT NULL UNIQUE,
position INT NOT NULL DEFAULT 0,
is_done BOOLEAN NOT NULL DEFAULT FALSE,
is_hold BOOLEAN NOT NULL DEFAULT FALSE
);
-- Flat, shared classification labels (e.g. "Finance", "Maintenance") — one
-- per task, not per-user, so they mean the same thing across owners/assignees.
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
colour TEXT NOT NULL DEFAULT '#c9a84c',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- A grouping container for tasks that can span multiple owners (once a task
-- is assigned elsewhere it keeps its project_id). Progress is inferred from
-- child tasks' statuses — projects don't get their own status pipeline.
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
created_by TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
colour TEXT NOT NULL DEFAULT '#c9a84c',
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- A single to-do. assigned_to is a real ownership transfer (gated behind the
-- 'assign' cap) — when set, owner_id is moved to match it and the task is
-- unfiled (list_id NULL) since the assignee doesn't share the old owner's
-- lists. task_tags (below) is the separate, cap-free "share/watch" path that
-- leaves ownership alone. recur_rule drives the spawn-next-on-complete
-- behaviour; remind_at/reminded_at drive the reminder sweep.
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
list_id INT REFERENCES lists(id) ON DELETE CASCADE,
owner_id TEXT NOT NULL,
created_by TEXT NOT NULL,
assigned_to TEXT,
category_id INT REFERENCES categories(id) ON DELETE SET NULL,
project_id INT REFERENCES projects(id) ON DELETE SET NULL,
title TEXT NOT NULL,
notes TEXT,
status TEXT NOT NULL DEFAULT 'todo' REFERENCES statuses(value),
priority TEXT NOT NULL DEFAULT 'medium', -- low | medium | high
due_at TIMESTAMPTZ,
remind_at TIMESTAMPTZ,
reminded_at TIMESTAMPTZ,
recur_rule TEXT, -- null | daily | weekdays | weekly | monthly
completed_at TIMESTAMPTZ,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS tasks_list_idx ON tasks (list_id, sort_order);
CREATE INDEX IF NOT EXISTS tasks_owner_idx ON tasks (owner_id, status);
CREATE INDEX IF NOT EXISTS tasks_due_idx ON tasks (owner_id, due_at);
CREATE INDEX IF NOT EXISTS tasks_remind_idx ON tasks (remind_at) WHERE reminded_at IS NULL;
CREATE INDEX IF NOT EXISTS tasks_project_idx ON tasks (project_id);
CREATE INDEX IF NOT EXISTS tasks_category_idx ON tasks (category_id);
-- Lightweight share/watch — a tagged user sees the task in their "Shared
-- with me" view and can comment/tick items, but it stays on the owner's
-- list. No capability required to tag someone in (unlike assigned_to).
CREATE TABLE IF NOT EXISTS task_tags (
task_id INT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
user_email TEXT NOT NULL,
tagged_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (task_id, user_email)
);
CREATE INDEX IF NOT EXISTS task_tags_user_idx ON task_tags (user_email);
-- Checklist rows within a task ("buy X, buy Y").
CREATE TABLE IF NOT EXISTS task_items (
id SERIAL PRIMARY KEY,
task_id INT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
label TEXT NOT NULL,
is_done BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS task_items_task_idx ON task_items (task_id, sort_order);
-- Activity / notes thread on a task — visible to the owner and anyone
-- tagged in via task_tags.
CREATE TABLE IF NOT EXISTS task_comments (
id SERIAL PRIMARY KEY,
task_id INT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
author_email TEXT,
author_name TEXT,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS task_comments_task_idx ON task_comments (task_id, created_at);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id SERIAL PRIMARY KEY,
user_email TEXT NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS push_subs_email_idx ON push_subscriptions (user_email);
`)
// Safety net for the pre-existing NOT NULL on list_id, in case this ran
// against a database created before assignment support (drop is a no-op
// if the constraint is already gone).
await pool.query(`ALTER TABLE tasks ALTER COLUMN list_id DROP NOT NULL`)
await seedDefaults()
await seedStatuses()
}
async function seedDefaults() {
const defaults = {
remind_by_push: true, // fire a web-push notification when a reminder is due
remind_by_email: false, // also email the owner (uses stack SMTP)
}
for (const [key, value] of Object.entries(defaults)) {
await pool.query(
`INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
[key, JSON.stringify(value)]
)
}
}
async function seedStatuses() {
const defaults = [
{ label: 'To Do', value: 'todo', position: 0, is_done: false, is_hold: false },
{ label: 'In Progress', value: 'doing', position: 1, is_done: false, is_hold: false },
{ label: 'In Review', value: 'review', position: 2, is_done: false, is_hold: false },
{ label: 'On Hold', value: 'hold', position: 3, is_done: false, is_hold: true },
{ label: 'Done', value: 'done', position: 4, is_done: true, is_hold: false },
]
for (const s of defaults) {
await pool.query(
`INSERT INTO statuses (label, value, position, is_done, is_hold) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (value) DO NOTHING`,
[s.label, s.value, s.position, s.is_done, s.is_hold]
)
}
}
export async function getStatuses() {
const { rows } = await pool.query('SELECT * FROM statuses ORDER BY position, id')
return rows
}
export async function getConfig() {
const { rows } = await pool.query('SELECT key, value FROM config')
return Object.fromEntries(rows.map(r => [r.key, r.value]))
}

40
backend/src/index.js Normal file
View file

@ -0,0 +1,40 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import { initDb } from './db.js'
import { startScheduler } from './lib/scheduler.js'
import { listRoutes } from './routes/lists.js'
import { taskRoutes } from './routes/tasks.js'
import { configRoutes } from './routes/config.js'
import { pushRoutes } from './routes/push.js'
import { statusRoutes } from './routes/statuses.js'
import { categoryRoutes } from './routes/categories.js'
import { projectRoutes } from './routes/projects.js'
const app = Fastify({ logger: true, trustProxy: true })
const startedAt = Date.now()
await app.register(cookie)
await app.register(cors, {
origin: process.env.CORS_ORIGIN || false,
credentials: true,
})
app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) }))
await app.register(listRoutes)
await app.register(taskRoutes)
await app.register(configRoutes)
await app.register(pushRoutes)
await app.register(statusRoutes)
await app.register(categoryRoutes)
await app.register(projectRoutes)
try {
await initDb()
startScheduler(app)
await app.listen({ port: 3001, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}

80
backend/src/ip-check.js Normal file
View file

@ -0,0 +1,80 @@
import dns from 'dns/promises'
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
const TTL = 5 * 60 * 1000
const cache = new Map()
const PUBLIC_IP_URLS = [
'https://api.ipify.org',
'https://ifconfig.co/ip',
'https://icanhazip.com',
]
function normalizeIP(ip) {
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
}
function isIPv4(s) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
}
function ipInCidr(ip, cidr) {
const [range, bits] = cidr.split('/')
if (!isIPv4(ip) || !isIPv4(range)) return false
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
return (toInt(ip) & mask) === (toInt(range) & mask)
}
async function fetchPublicIP() {
for (const url of PUBLIC_IP_URLS) {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 4000)
const res = await fetch(url, { signal: ctrl.signal })
clearTimeout(timer)
if (!res.ok) continue
const ip = (await res.text()).trim()
if (isIPv4(ip)) return ip
} catch {
// try next
}
}
return null
}
async function resolveDynamic(key, resolver) {
const hit = cache.get(key)
if (hit && Date.now() < hit.expiry) return hit.ip
const ip = await resolver()
if (ip) {
cache.set(key, { ip, expiry: Date.now() + TTL })
return ip
}
return hit ? hit.ip : null
}
export async function isOnsite(requestIP) {
if (matchers.length === 0 || matchers.includes('disabled')) return true
const ip = normalizeIP(requestIP)
if (!ip) return false
for (const m of matchers) {
if (m === 'auto') {
const pub = await resolveDynamic('auto', fetchPublicIP)
if (pub && ip === pub) return true
} else if (m.includes('/')) {
if (ipInCidr(ip, m)) return true
} else if (/[a-zA-Z]/.test(m)) {
const resolved = await resolveDynamic(m, async () => {
try { return (await dns.resolve4(m))[0] } catch { return null }
})
if (resolved && ip === resolved) return true
} else {
if (ip === m) return true
}
}
return false
}

53
backend/src/lib/mailer.js Normal file
View file

@ -0,0 +1,53 @@
import nodemailer from 'nodemailer'
const SETTINGS_URL = process.env.SETTINGS_URL || ''
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
let _smtpCache = null // { config, expires_at }
let _transporter = null
async function getSmtpConfig() {
if (_smtpCache && Date.now() < _smtpCache.expires_at) return _smtpCache.config
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/smtp`, {
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`Failed to fetch SMTP config from settings: ${res.status}`)
const config = await res.json()
if (!config.host) throw new Error('SMTP not configured in settings')
_smtpCache = { config, expires_at: Date.now() + 5 * 60_000 }
_transporter = null
return config
}
async function getTransporter() {
if (_transporter) return _transporter
const config = await getSmtpConfig()
const port = parseInt(config.port || '587')
_transporter = nodemailer.createTransport({
host: config.host,
port,
secure: port === 465,
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
})
return _transporter
}
const HOTEL_NAME = process.env.VITE_HOTEL_NAME || 'Hotel'
// Fire-and-forget: email failure must never block a task write or the scheduler.
export async function sendMail(to, subject, text) {
if (!to) return
try {
const config = await getSmtpConfig()
const transport = await getTransporter()
await transport.sendMail({
from: config.from || `"${HOTEL_NAME} Tasks" <noreply@localhost>`,
to,
subject,
text,
})
} catch (err) {
console.error(`Tasks mail to ${to} failed: ${err.message}`)
}
}

28
backend/src/lib/notify.js Normal file
View file

@ -0,0 +1,28 @@
import { getConfig } from '../db.js'
import { sendPushToUser } from './push.js'
import { sendMail } from './mailer.js'
// Reminder delivery for a single task. Honours the app-wide push/email toggles
// (config table). Both channels are fire-and-forget — a failure here must never
// stop the scheduler stamping reminded_at, or the same reminder fires forever.
export async function notifyReminder(task) {
const cfg = await getConfig().catch(() => ({}))
const target = task.assigned_to || task.owner_id
if (!target) return
if (cfg.remind_by_push !== false) {
await sendPushToUser(target, {
title: `Reminder: ${task.title}`,
body: task.due_at ? `Due ${new Date(task.due_at).toLocaleString('en-GB')}` : 'Task reminder',
url: '/tasks/',
tag: `task-${task.id}`,
}).catch(err => console.error('[notify] push failed:', err.message))
}
if (cfg.remind_by_email === true) {
const lines = [`Reminder for your task:`, ``, task.title]
if (task.due_at) lines.push(`Due: ${new Date(task.due_at).toLocaleString('en-GB')}`)
if (task.notes) lines.push('', task.notes)
await sendMail(target, `[Tasks] Reminder: ${task.title}`, lines.join('\n'))
}
}

73
backend/src/lib/push.js Normal file
View file

@ -0,0 +1,73 @@
import webPush from 'web-push'
import { pool } from '../db.js'
let _publicKey = null
// VAPID keys: prefer env vars; fall back to auto-generated keys stored in config table.
// Keys are generated once on first boot and persist — regenerating would invalidate all
// existing subscriptions.
export async function ensureVapid() {
if (_publicKey) return _publicKey
let publicKey = process.env.VAPID_PUBLIC_KEY
let privateKey = process.env.VAPID_PRIVATE_KEY
const subject = process.env.VAPID_SUBJECT || 'mailto:noreply@localhost'
if (!publicKey || !privateKey) {
const { rows } = await pool.query("SELECT value FROM config WHERE key = 'vapid_keys'")
if (rows.length && rows[0].value?.publicKey) {
publicKey = rows[0].value.publicKey
privateKey = rows[0].value.privateKey
} else {
const keys = webPush.generateVAPIDKeys()
publicKey = keys.publicKey
privateKey = keys.privateKey
await pool.query(
"INSERT INTO config (key, value) VALUES ('vapid_keys', $1) ON CONFLICT (key) DO UPDATE SET value = $1",
[JSON.stringify({ publicKey, privateKey })]
)
}
}
webPush.setVapidDetails(subject, publicKey, privateKey)
_publicKey = publicKey
return publicKey
}
export async function sendPushToUser(userEmail, payload) {
await ensureVapid()
const { rows } = await pool.query(
'SELECT * FROM push_subscriptions WHERE user_email = $1',
[userEmail]
)
if (!rows.length) return
const results = await Promise.allSettled(
rows.map(sub =>
webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload)
)
)
)
// Clean up expired or invalid subscriptions
for (let i = 0; i < results.length; i++) {
const r = results[i]
if (r.status === 'rejected' && [404, 410].includes(r.reason?.statusCode)) {
await pool.query('DELETE FROM push_subscriptions WHERE id = $1', [rows[i].id]).catch(() => {})
}
}
}
// targetEmail overrides task.assigned_to (used when reassigning via PATCH before DB update)
export function notifyAssignmentPush(task, locationName, targetEmail = null) {
const email = targetEmail || (task.assigned_type === 'staff' ? task.assigned_to : null)
if (!email) return Promise.resolve()
return sendPushToUser(email, {
title: `New task: ${task.title}`,
body: `${locationName} — ${task.priority} priority`,
url: '/maintenance/',
tag: `maint-task-${task.id}`,
}).catch(err => console.error('[push] notify failed:', err.message))
}

View file

@ -0,0 +1,25 @@
export const RECUR_RULES = ['daily', 'weekdays', 'weekly', 'monthly']
// Given a recurrence rule and a base date, return the next occurrence date.
// Personal tasks recur on completion (Todoist-style): when a recurring task is
// ticked off we spawn a fresh copy dated one interval on from its old due date
// (or from `now` if it had no due date). Returns a Date, or null for no rule.
export function nextOccurrence(rule, from) {
if (!rule || !RECUR_RULES.includes(rule)) return null
const d = new Date(from)
switch (rule) {
case 'daily':
d.setDate(d.getDate() + 1)
break
case 'weekdays':
do { d.setDate(d.getDate() + 1) } while (d.getDay() === 0 || d.getDay() === 6)
break
case 'weekly':
d.setDate(d.getDate() + 7)
break
case 'monthly':
d.setMonth(d.getMonth() + 1)
break
}
return d
}

View file

@ -0,0 +1,34 @@
import { pool } from '../db.js'
import { notifyReminder } from './notify.js'
const CHECK_INTERVAL_MS = 5 * 60 * 1000 // every 5 minutes
// Fire any reminders whose remind_at has passed and haven't been sent yet.
// reminded_at is stamped first so a delivery failure never re-fires the reminder,
// and so a backlog after downtime sends one notification per task, not per tick.
export async function runReminderSweep(log = console) {
const { rows: due } = await pool.query(
`UPDATE tasks
SET reminded_at = NOW()
WHERE remind_at IS NOT NULL
AND reminded_at IS NULL
AND remind_at <= NOW()
AND status <> 'done'
RETURNING *`
)
for (const task of due) {
try {
await notifyReminder(task)
log.info?.(`Reminder sent for task ${task.id} (${task.title})`)
} catch (err) {
log.error?.(`Reminder failed for task ${task.id}: ${err.message}`)
}
}
return due.length
}
export function startScheduler(app) {
runReminderSweep(app.log).catch(err => app.log.error(err))
setInterval(() => runReminderSweep(app.log).catch(err => app.log.error(err)), CHECK_INTERVAL_MS)
}

View file

@ -0,0 +1,52 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function categoryRoutes(app) {
app.addHook('preHandler', requireAuth)
// Shared across all users — everyone with 'use' picks from the same set.
app.get('/api/categories', { preHandler: requireCap('use') }, async () => {
const { rows } = await pool.query('SELECT * FROM categories ORDER BY sort_order, name')
return rows
})
app.post('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => {
const { name, colour } = req.body || {}
if (!name?.trim()) return reply.status(400).send({ error: 'name required' })
const { rows: [max] } = await pool.query('SELECT COALESCE(MAX(sort_order), -1) AS m FROM categories')
try {
const { rows: [category] } = await pool.query(
`INSERT INTO categories (name, colour, sort_order) VALUES ($1, COALESCE($2, '#c9a84c'), $3) RETURNING *`,
[name.trim(), colour || null, Number(max.m) + 1]
)
return category
} catch (err) {
if (err.code === '23505') return reply.status(409).send({ error: 'a category with that name already exists' })
throw err
}
})
app.patch('/api/categories/:id', { preHandler: requireCap('settings') }, async (req, reply) => {
const fields = []
const vals = []
let i = 1
for (const key of ['name', 'colour', 'sort_order']) {
if (req.body?.[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(req.body[key]) }
}
if (!fields.length) return reply.status(400).send({ error: 'nothing to update' })
vals.push(req.params.id)
const { rows } = await pool.query(
`UPDATE categories SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`,
vals
)
if (!rows.length) return reply.status(404).send({ error: 'category not found' })
return rows[0]
})
app.delete('/api/categories/:id', { preHandler: requireCap('settings') }, async (req, reply) => {
// Tasks referencing this category fall back to uncategorised (ON DELETE SET NULL).
const { rowCount } = await pool.query('DELETE FROM categories WHERE id = $1', [req.params.id])
if (!rowCount) return reply.status(404).send({ error: 'category not found' })
return { ok: true }
})
}

View file

@ -0,0 +1,24 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, getConfig } from '../db.js'
// Only these keys are writable through the API.
const ALLOWED_KEYS = new Set(['remind_by_push', 'remind_by_email'])
export async function configRoutes(app) {
app.addHook('preHandler', requireAuth)
app.get('/api/config', { preHandler: requireCap('use') }, async () => {
return getConfig()
})
app.put('/api/config/:key', { preHandler: requireCap('settings') }, async (req, reply) => {
const { key } = req.params
if (!ALLOWED_KEYS.has(key)) return reply.status(400).send({ error: 'unknown setting' })
await pool.query(
`INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, JSON.stringify(req.body?.value ?? null)]
)
return { ok: true }
})
}

View file

@ -0,0 +1,66 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function listRoutes(app) {
app.addHook('preHandler', requireAuth)
// All of a user's lists, each with its open (non-done) task count.
app.get('/api/lists', { preHandler: requireCap('use') }, async (req) => {
const includeArchived = req.query.archived === '1' || req.query.archived === 'true'
const { rows } = await pool.query(
`SELECT l.*,
COUNT(t.id) FILTER (WHERE t.status <> 'done') AS open_count,
COUNT(t.id) AS total_count
FROM lists l
LEFT JOIN tasks t ON t.list_id = l.id
WHERE l.owner_id = $1
AND ($2::boolean OR l.is_archived = FALSE)
GROUP BY l.id
ORDER BY l.sort_order, l.id`,
[req.user.email, includeArchived]
)
return rows.map(r => ({ ...r, open_count: Number(r.open_count), total_count: Number(r.total_count) }))
})
app.post('/api/lists', { preHandler: requireCap('use') }, async (req, reply) => {
const { name, colour, icon } = req.body || {}
if (!name?.trim()) return reply.status(400).send({ error: 'name required' })
const { rows: [max] } = await pool.query(
'SELECT COALESCE(MAX(sort_order), 0) AS m FROM lists WHERE owner_id = $1',
[req.user.email]
)
const { rows: [list] } = await pool.query(
`INSERT INTO lists (owner_id, name, colour, icon, sort_order)
VALUES ($1, $2, COALESCE($3, '#c9a84c'), COALESCE($4, 'ListChecks'), $5)
RETURNING *`,
[req.user.email, name.trim(), colour || null, icon || null, Number(max.m) + 1]
)
return list
})
app.patch('/api/lists/:id', { preHandler: requireCap('use') }, async (req, reply) => {
const fields = []
const vals = []
let i = 1
for (const key of ['name', 'colour', 'icon', 'is_archived', 'sort_order']) {
if (req.body?.[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(req.body[key]) }
}
if (!fields.length) return reply.status(400).send({ error: 'nothing to update' })
vals.push(req.params.id, req.user.email)
const { rows } = await pool.query(
`UPDATE lists SET ${fields.join(', ')} WHERE id = $${i++} AND owner_id = $${i} RETURNING *`,
vals
)
if (!rows.length) return reply.status(404).send({ error: 'list not found' })
return rows[0]
})
app.delete('/api/lists/:id', { preHandler: requireCap('use') }, async (req, reply) => {
const { rowCount } = await pool.query(
'DELETE FROM lists WHERE id = $1 AND owner_id = $2',
[req.params.id, req.user.email]
)
if (!rowCount) return reply.status(404).send({ error: 'list not found' })
return { ok: true }
})
}

View file

@ -0,0 +1,92 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
// A project is visible to whoever created it, plus anyone who owns or is
// tagged on at least one task inside it — so a project stays visible to
// collaborators even though it isn't "theirs". `alias` lets the same clause
// be reused against different table aliases in one query.
const visibleTask = (alias) => `(${alias}.owner_id = $1 OR ${alias}.assigned_to = $1 OR EXISTS (
SELECT 1 FROM task_tags tt WHERE tt.task_id = ${alias}.id AND tt.user_email = $1
))`
async function canManageProject(email, isAdmin, projectId) {
if (isAdmin) return true
const { rows: [p] } = await pool.query('SELECT created_by FROM projects WHERE id = $1', [projectId])
return p?.created_by === email
}
export async function projectRoutes(app) {
app.addHook('preHandler', requireAuth)
app.get('/api/projects', { preHandler: requireCap('use') }, async (req) => {
const { rows } = await pool.query(
`SELECT p.*,
COUNT(t.id) AS task_count,
COUNT(t.id) FILTER (WHERE s.is_done) AS done_count
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id AND ${visibleTask('t')}
LEFT JOIN statuses s ON s.value = t.status
WHERE p.is_archived = FALSE
AND (p.created_by = $1 OR EXISTS (
SELECT 1 FROM tasks t2 WHERE t2.project_id = p.id AND ${visibleTask('t2')}
))
GROUP BY p.id
ORDER BY p.sort_order, p.id`,
[req.user.email]
)
return rows.map(r => ({ ...r, task_count: Number(r.task_count), done_count: Number(r.done_count) }))
})
app.get('/api/projects/:id', { preHandler: requireCap('use') }, async (req, reply) => {
const { rows: [project] } = await pool.query('SELECT * FROM projects WHERE id = $1', [req.params.id])
if (!project) return reply.status(404).send({ error: 'project not found' })
const { rows: tasks } = await pool.query(
`SELECT t.*, s.label AS status_label, s.is_done, s.is_hold
FROM tasks t
JOIN statuses s ON s.value = t.status
WHERE t.project_id = $2 AND ${visibleTask('t')}
ORDER BY s.position, t.sort_order, t.id`,
[req.user.email, req.params.id]
)
return { ...project, tasks }
})
app.post('/api/projects', { preHandler: requireCap('use') }, async (req, reply) => {
const { name, description, colour } = req.body || {}
if (!name?.trim()) return reply.status(400).send({ error: 'name required' })
const { rows: [max] } = await pool.query('SELECT COALESCE(MAX(sort_order), -1) AS m FROM projects')
const { rows: [project] } = await pool.query(
`INSERT INTO projects (created_by, name, description, colour, sort_order)
VALUES ($1, $2, $3, COALESCE($4, '#c9a84c'), $5) RETURNING *`,
[req.user.email, name.trim(), description || null, colour || null, Number(max.m) + 1]
)
return project
})
app.patch('/api/projects/:id', { preHandler: requireCap('use') }, async (req, reply) => {
if (!(await canManageProject(req.user.email, req.user.is_admin, req.params.id)))
return reply.status(403).send({ error: 'only the project creator can edit this' })
const fields = []
const vals = []
let i = 1
for (const key of ['name', 'description', 'colour', 'is_archived', 'sort_order']) {
if (req.body?.[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(req.body[key]) }
}
if (!fields.length) return reply.status(400).send({ error: 'nothing to update' })
vals.push(req.params.id)
const { rows } = await pool.query(
`UPDATE projects SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`,
vals
)
if (!rows.length) return reply.status(404).send({ error: 'project not found' })
return rows[0]
})
app.delete('/api/projects/:id', { preHandler: requireCap('use') }, async (req, reply) => {
if (!(await canManageProject(req.user.email, req.user.is_admin, req.params.id)))
return reply.status(403).send({ error: 'only the project creator can delete this' })
const { rowCount } = await pool.query('DELETE FROM projects WHERE id = $1', [req.params.id])
if (!rowCount) return reply.status(404).send({ error: 'project not found' })
return { ok: true }
})
}

View file

@ -0,0 +1,36 @@
import { requireAuth } from '../auth.js'
import { pool } from '../db.js'
import { ensureVapid } from '../lib/push.js'
export async function pushRoutes(app) {
app.addHook('preHandler', requireAuth)
app.get('/api/push/vapid-key', async () => {
const publicKey = await ensureVapid()
return { publicKey }
})
app.post('/api/push/subscribe', async (req, reply) => {
const { endpoint, keys } = req.body || {}
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return reply.status(400).send({ error: 'endpoint and keys (p256dh, auth) required' })
}
await pool.query(
`INSERT INTO push_subscriptions (user_email, endpoint, p256dh, auth)
VALUES ($1, $2, $3, $4)
ON CONFLICT (endpoint) DO UPDATE SET user_email = $1, p256dh = $3, auth = $4, updated_at = NOW()`,
[req.user.email, endpoint, keys.p256dh, keys.auth]
)
return { ok: true }
})
app.delete('/api/push/unsubscribe', async (req, reply) => {
const { endpoint } = req.body || {}
if (endpoint) {
await pool.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [endpoint])
} else {
await pool.query('DELETE FROM push_subscriptions WHERE user_email = $1', [req.user.email])
}
return { ok: true }
})
}

View file

@ -0,0 +1,50 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, getStatuses } from '../db.js'
export async function statusRoutes(app) {
app.addHook('preHandler', requireAuth)
app.get('/api/statuses', { preHandler: requireCap('use') }, async () => {
return getStatuses()
})
app.post('/api/statuses', { preHandler: requireCap('settings') }, async (req, reply) => {
const { label, value, is_hold } = req.body || {}
if (!label?.trim() || !value?.trim()) return reply.status(400).send({ error: 'label and value required' })
if (!/^[a-z0-9_]+$/.test(value)) return reply.status(400).send({ error: 'value must be lowercase letters, numbers, underscores' })
const { rows: [max] } = await pool.query('SELECT COALESCE(MAX(position), -1) AS m FROM statuses')
const { rows: [status] } = await pool.query(
`INSERT INTO statuses (label, value, position, is_hold) VALUES ($1, $2, $3, $4) RETURNING *`,
[label.trim(), value.trim(), Number(max.m) + 1, !!is_hold]
)
return status
})
app.patch('/api/statuses/:id', { preHandler: requireCap('settings') }, async (req, reply) => {
const fields = []
const vals = []
let i = 1
for (const key of ['label', 'position', 'is_done', 'is_hold']) {
if (req.body?.[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(req.body[key]) }
}
if (!fields.length) return reply.status(400).send({ error: 'nothing to update' })
vals.push(req.params.id)
const { rows } = await pool.query(
`UPDATE statuses SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`,
vals
)
if (!rows.length) return reply.status(404).send({ error: 'status not found' })
return rows[0]
})
// A status can only be removed once nothing references it — reassign any
// tasks first (the frontend should prompt for a replacement column).
app.delete('/api/statuses/:id', { preHandler: requireCap('settings') }, async (req, reply) => {
const { rows: [status] } = await pool.query('SELECT value FROM statuses WHERE id = $1', [req.params.id])
if (!status) return reply.status(404).send({ error: 'status not found' })
const { rows: [inUse] } = await pool.query('SELECT 1 FROM tasks WHERE status = $1 LIMIT 1', [status.value])
if (inUse) return reply.status(409).send({ error: 'status is still in use by tasks' })
await pool.query('DELETE FROM statuses WHERE id = $1', [req.params.id])
return { ok: true }
})
}

366
backend/src/routes/tasks.js Normal file
View file

@ -0,0 +1,366 @@
import { requireAuth, requireCap, hasCap } from '../auth.js'
import { pool } from '../db.js'
import { nextOccurrence, RECUR_RULES } from '../lib/recurrence.js'
const PRIORITIES = ['low', 'medium', 'high']
async function getStatus(value) {
const { rows: [s] } = await pool.query('SELECT * FROM statuses WHERE value = $1', [value])
return s || null
}
// Load a task only if the caller owns it — the guard used for full edits
// (reassigning, deleting, changing status/fields). Tagging someone in does
// NOT grant this.
async function loadOwned(id, email) {
const { rows } = await pool.query(
'SELECT * FROM tasks WHERE id = $1 AND owner_id = $2',
[id, email]
)
return rows[0] || null
}
// Load a task the caller can see: they own it, it was assigned to them, or
// they've been tagged in. Used for read access and collaborative actions
// (comments, checklist items) that a tagged-in collaborator should be able
// to do without full edit rights.
async function loadVisible(id, email) {
const { rows } = await pool.query(
`SELECT t.* FROM tasks t
WHERE t.id = $1 AND (t.owner_id = $2 OR t.assigned_to = $2 OR EXISTS (
SELECT 1 FROM task_tags tt WHERE tt.task_id = t.id AND tt.user_email = $2
))`,
[id, email]
)
return rows[0] || null
}
// Spawn the next occurrence of a recurring task when it's completed. The fresh
// copy keeps the title/notes/priority/list/category/project/rule and a reset
// checklist, dated one interval on from the old due date (or from now if it
// had none).
async function spawnRecurrence(task) {
if (!task.recur_rule || !RECUR_RULES.includes(task.recur_rule)) return
const base = task.due_at ? new Date(task.due_at) : new Date()
const newDue = nextOccurrence(task.recur_rule, base)
let newRemind = null
if (task.remind_at) {
if (task.due_at) {
const delta = new Date(task.due_at).getTime() - new Date(task.remind_at).getTime()
newRemind = new Date(newDue.getTime() - delta)
} else {
newRemind = nextOccurrence(task.recur_rule, new Date(task.remind_at))
}
}
const { rows: [next] } = await pool.query(
`INSERT INTO tasks (list_id, owner_id, created_by, category_id, project_id, title, notes, priority, due_at, remind_at, recur_rule, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
[task.list_id, task.owner_id, task.created_by, task.category_id, task.project_id, task.title, task.notes,
task.priority, newDue, newRemind, task.recur_rule, task.sort_order]
)
await pool.query(
`INSERT INTO task_items (task_id, label, is_done, sort_order)
SELECT $1, label, FALSE, sort_order FROM task_items WHERE task_id = $2`,
[next.id, task.id]
)
}
export async function taskRoutes(app) {
app.addHook('preHandler', requireAuth)
// List tasks. ?view=today = cross-list agenda (non-done, due within 8 days
// or overdue). ?view=assigned = tasks handed to me via the 'assign' cap.
// ?view=shared = tasks I've been tagged into (not mine, not assigned to me).
// ?view=all = every user's tasks, dispatcher board for the 'reallocate' cap.
// Otherwise defaults to everything I own. ?list_id/?status/?category_id/
// ?project_id narrow further.
app.get('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => {
const { list_id, view, status, category_id, project_id } = req.query
const vals = [req.user.email]
let i = 2
const clauses = []
let joinTags = ''
if (view === 'all') {
if (!hasCap(req, 'reallocate')) return reply.status(403).send({ error: 'Missing capability: reallocate' })
// $1 (email) isn't needed for the filter itself, but every other branch
// binds it — keep it referenced so the placeholder numbering below stays
// valid (an unused bind param makes Postgres reject the query outright).
clauses.push('$1::text IS NOT NULL')
} else if (view === 'shared') {
joinTags = 'JOIN task_tags tt ON tt.task_id = t.id AND tt.user_email = $1'
clauses.push('TRUE')
} else if (view === 'assigned') {
clauses.push(`t.assigned_to = $1`)
} else {
clauses.push(`t.owner_id = $1`)
}
if (list_id) { clauses.push(`t.list_id = $${i++}`); vals.push(list_id) }
if (status) { clauses.push(`t.status = $${i++}`); vals.push(status) }
if (category_id) { clauses.push(`t.category_id = $${i++}`); vals.push(category_id) }
if (project_id) { clauses.push(`t.project_id = $${i++}`); vals.push(project_id) }
if (view === 'today') {
clauses.push(`NOT s.is_done`)
clauses.push(`t.due_at IS NOT NULL`)
clauses.push(`t.due_at < (NOW() + INTERVAL '8 days')`)
}
const { rows } = await pool.query(
`SELECT t.*, l.name AS list_name, l.colour AS list_colour,
s.label AS status_label, s.is_done, s.is_hold,
c.name AS category_name, c.colour AS category_colour,
p.name AS project_name, p.colour AS project_colour,
(SELECT COUNT(*) FROM task_items ti WHERE ti.task_id = t.id) AS item_count,
(SELECT COUNT(*) FROM task_items ti WHERE ti.task_id = t.id AND ti.is_done) AS item_done
FROM tasks t
${joinTags}
LEFT JOIN lists l ON l.id = t.list_id
JOIN statuses s ON s.value = t.status
LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN projects p ON p.id = t.project_id
WHERE ${clauses.join(' AND ')}
ORDER BY s.is_done, s.position, t.sort_order, t.due_at NULLS LAST, t.id`,
vals
)
return rows.map(r => ({ ...r, item_count: Number(r.item_count), item_done: Number(r.item_done) }))
})
app.get('/api/tasks/:id', { preHandler: requireCap('use') }, async (req, reply) => {
const task = await loadVisible(req.params.id, req.user.email)
if (!task) return reply.status(404).send({ error: 'task not found' })
const { rows: items } = await pool.query(
'SELECT * FROM task_items WHERE task_id = $1 ORDER BY sort_order, id',
[task.id]
)
const { rows: comments } = await pool.query(
'SELECT * FROM task_comments WHERE task_id = $1 ORDER BY created_at',
[task.id]
)
const { rows: tags } = await pool.query(
'SELECT user_email, tagged_by, created_at FROM task_tags WHERE task_id = $1 ORDER BY created_at',
[task.id]
)
return { ...task, items, comments, tags }
})
app.post('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => {
const { list_id, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id } = req.body || {}
if (!title?.trim()) return reply.status(400).send({ error: 'title required' })
// Verify the target list belongs to the caller.
const { rows: [list] } = await pool.query(
'SELECT id FROM lists WHERE id = $1 AND owner_id = $2',
[list_id, req.user.email]
)
if (!list) return reply.status(400).send({ error: 'invalid list' })
if (priority && !PRIORITIES.includes(priority)) return reply.status(400).send({ error: 'bad priority' })
if (recur_rule && !RECUR_RULES.includes(recur_rule)) return reply.status(400).send({ error: 'bad recur_rule' })
if (category_id) {
const { rows: [c] } = await pool.query('SELECT id FROM categories WHERE id = $1', [category_id])
if (!c) return reply.status(400).send({ error: 'invalid category' })
}
if (project_id) {
const { rows: [p] } = await pool.query('SELECT id FROM projects WHERE id = $1', [project_id])
if (!p) return reply.status(400).send({ error: 'invalid project' })
}
const { rows: [max] } = await pool.query(
'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id = $1',
[list_id]
)
const { rows: [task] } = await pool.query(
`INSERT INTO tasks (list_id, owner_id, created_by, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id, sort_order)
VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11)
RETURNING *`,
[list_id, req.user.email, title.trim(), notes || null, priority || null,
due_at || null, remind_at || null, recur_rule || null, category_id || null, project_id || null, Number(max.m) + 1]
)
return task
})
app.patch('/api/tasks/:id', { preHandler: requireCap('use') }, async (req, reply) => {
const task = await loadOwned(req.params.id, req.user.email)
if (!task) return reply.status(404).send({ error: 'task not found' })
const b = req.body || {}
let newStatus = null
if (b.status !== undefined) {
newStatus = await getStatus(b.status)
if (!newStatus) return reply.status(400).send({ error: 'bad status' })
}
if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'bad priority' })
if (b.recur_rule && !RECUR_RULES.includes(b.recur_rule)) return reply.status(400).send({ error: 'bad recur_rule' })
if (b.list_id !== undefined && b.list_id !== null) {
const { rows: [l] } = await pool.query('SELECT id FROM lists WHERE id = $1 AND owner_id = $2', [b.list_id, req.user.email])
if (!l) return reply.status(400).send({ error: 'invalid list' })
}
if (b.category_id !== undefined && b.category_id !== null) {
const { rows: [c] } = await pool.query('SELECT id FROM categories WHERE id = $1', [b.category_id])
if (!c) return reply.status(400).send({ error: 'invalid category' })
}
if (b.project_id !== undefined && b.project_id !== null) {
const { rows: [p] } = await pool.query('SELECT id FROM projects WHERE id = $1', [b.project_id])
if (!p) return reply.status(400).send({ error: 'invalid project' })
}
// Assigning is a real ownership transfer, gated behind the 'assign' cap —
// separate from tagging, which anyone can do to share a task.
if (b.assigned_to !== undefined) {
if (!hasCap(req, 'assign')) return reply.status(403).send({ error: "Missing capability: assign" })
}
const fields = []
const vals = []
let i = 1
for (const key of ['title', 'notes', 'priority', 'due_at', 'remind_at', 'recur_rule', 'sort_order', 'category_id', 'project_id']) {
if (b[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(b[key] === '' ? null : b[key]) }
}
// list_id can be explicitly nulled (unfiled) as well as changed.
if (b.list_id !== undefined) { fields.push(`list_id = $${i++}`); vals.push(b.list_id || null) }
// Reset the reminder clock whenever remind_at is (re)set, so the sweep re-fires it.
if (b.remind_at !== undefined) fields.push('reminded_at = NULL')
if (b.assigned_to !== undefined) {
const target = b.assigned_to || null
fields.push(`assigned_to = $${i++}`); vals.push(target)
// Handing off moves the task onto the assignee's board — they don't
// share the old owner's lists, so it lands unfiled. Clearing the
// assignment (target === null) just removes the marker; ownership stays put.
if (target && target !== task.owner_id) {
fields.push(`owner_id = $${i++}`); vals.push(target)
// Only force list_id to NULL if this request didn't already set it explicitly.
if (b.list_id === undefined) fields.push(`list_id = NULL`)
}
}
const oldStatus = newStatus ? await getStatus(task.status) : null
const wasDone = !!oldStatus?.is_done
if (newStatus) {
fields.push(`status = $${i++}`); vals.push(b.status)
if (newStatus.is_done && !wasDone) fields.push('completed_at = NOW()')
if (!newStatus.is_done && wasDone) fields.push('completed_at = NULL')
}
fields.push('updated_at = NOW()')
vals.push(task.id, req.user.email)
const { rows: [updated] } = await pool.query(
`UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i++} AND owner_id = $${i} RETURNING *`,
vals
)
if (newStatus?.is_done && !wasDone) await spawnRecurrence(task)
return updated
})
// Dispatcher move — the 'reallocate' cap can hand ANY task to ANY user,
// regardless of who currently owns it (unlike 'assign', which only lets an
// owner hand off their own work). Deliberately narrow: this only moves the
// task, it doesn't grant editing rights over someone else's task content.
app.post('/api/tasks/:id/reallocate', { preHandler: requireCap('reallocate') }, async (req, reply) => {
const to = req.body?.to?.trim()
if (!to) return reply.status(400).send({ error: 'to (email) required' })
const { rows: [updated] } = await pool.query(
`UPDATE tasks SET owner_id = $1, assigned_to = $1, list_id = NULL, updated_at = NOW()
WHERE id = $2 RETURNING *`,
[to, req.params.id]
)
if (!updated) return reply.status(404).send({ error: 'task not found' })
return updated
})
app.delete('/api/tasks/:id', { preHandler: requireCap('use') }, async (req, reply) => {
const { rowCount } = await pool.query(
'DELETE FROM tasks WHERE id = $1 AND owner_id = $2',
[req.params.id, req.user.email]
)
if (!rowCount) return reply.status(404).send({ error: 'task not found' })
return { ok: true }
})
// Persist a manual reorder within a list. Body: { ids: [taskId, ...] } in order.
app.post('/api/tasks/reorder', { preHandler: requireCap('use') }, async (req, reply) => {
const ids = req.body?.ids
if (!Array.isArray(ids)) return reply.status(400).send({ error: 'ids array required' })
for (let idx = 0; idx < ids.length; idx++) {
await pool.query(
'UPDATE tasks SET sort_order = $1 WHERE id = $2 AND owner_id = $3',
[idx, ids[idx], req.user.email]
)
}
return { ok: true }
})
// ── Sharing (tag/untag) — owner-managed, no cap required ────
app.post('/api/tasks/:id/tags', { preHandler: requireCap('use') }, async (req, reply) => {
const task = await loadOwned(req.params.id, req.user.email)
if (!task) return reply.status(404).send({ error: 'task not found' })
const email = req.body?.email?.trim()
if (!email) return reply.status(400).send({ error: 'email required' })
await pool.query(
`INSERT INTO task_tags (task_id, user_email, tagged_by) VALUES ($1, $2, $3)
ON CONFLICT (task_id, user_email) DO NOTHING`,
[task.id, email, req.user.email]
)
return { ok: true }
})
app.delete('/api/tasks/:id/tags/:email', { preHandler: requireCap('use') }, async (req, reply) => {
const task = await loadOwned(req.params.id, req.user.email)
if (!task) return reply.status(404).send({ error: 'task not found' })
await pool.query('DELETE FROM task_tags WHERE task_id = $1 AND user_email = $2', [task.id, req.params.email])
return { ok: true }
})
// ── Checklist items — owner or tagged collaborators ─────────
app.post('/api/tasks/:id/items', { preHandler: requireCap('use') }, async (req, reply) => {
const task = await loadVisible(req.params.id, req.user.email)
if (!task) return reply.status(404).send({ error: 'task not found' })
if (!req.body?.label?.trim()) return reply.status(400).send({ error: 'label required' })
const { rows: [max] } = await pool.query('SELECT COALESCE(MAX(sort_order), 0) AS m FROM task_items WHERE task_id = $1', [task.id])
const { rows: [item] } = await pool.query(
'INSERT INTO task_items (task_id, label, sort_order) VALUES ($1, $2, $3) RETURNING *',
[task.id, req.body.label.trim(), Number(max.m) + 1]
)
return item
})
app.patch('/api/tasks/items/:itemId', { preHandler: requireCap('use') }, async (req, reply) => {
const { rows: [existing] } = await pool.query('SELECT task_id FROM task_items WHERE id = $1', [req.params.itemId])
if (!existing || !(await loadVisible(existing.task_id, req.user.email)))
return reply.status(404).send({ error: 'item not found' })
const fields = []
const vals = []
let i = 1
for (const key of ['label', 'is_done', 'sort_order']) {
if (req.body?.[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(req.body[key]) }
}
if (!fields.length) return reply.status(400).send({ error: 'nothing to update' })
vals.push(req.params.itemId)
const { rows: [item] } = await pool.query(
`UPDATE task_items SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`,
vals
)
return item
})
app.delete('/api/tasks/items/:itemId', { preHandler: requireCap('use') }, async (req, reply) => {
const { rows: [existing] } = await pool.query('SELECT task_id FROM task_items WHERE id = $1', [req.params.itemId])
if (!existing || !(await loadVisible(existing.task_id, req.user.email)))
return reply.status(404).send({ error: 'item not found' })
await pool.query('DELETE FROM task_items WHERE id = $1', [req.params.itemId])
return { ok: true }
})
// ── Comments / activity — owner or tagged collaborators ─────
app.post('/api/tasks/:id/comments', { preHandler: requireCap('use') }, async (req, reply) => {
const task = await loadVisible(req.params.id, req.user.email)
if (!task) return reply.status(404).send({ error: 'task not found' })
if (!req.body?.body?.trim()) return reply.status(400).send({ error: 'body required' })
const { rows: [c] } = await pool.query(
`INSERT INTO task_comments (task_id, author_email, author_name, body)
VALUES ($1, $2, $3, $4) RETURNING *`,
[task.id, req.user.email, req.user.name, req.body.body.trim()]
)
return c
})
}

37
docker-compose.yml Normal file
View file

@ -0,0 +1,37 @@
services:
backend:
build: ./backend
security_opt:
- apparmor=unconfined
environment:
- DATABASE_URL=${DATABASE_URL}
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
- SETTINGS_URL=${SETTINGS_URL}
- SETTINGS_SECRET=${SETTINGS_SECRET}
- APP_SLUG=tasks
- VITE_HOTEL_NAME=${VITE_HOTEL_NAME}
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
interval: 10s
retries: 5
start_period: 20s
restart: unless-stopped
frontend:
build:
context: ./frontend
args:
VITE_HOTEL_NAME: ${VITE_HOTEL_NAME}
security_opt:
- apparmor=unconfined
ports:
- "${FRONTEND_PORT:-3080}:80"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
networks:
default:
driver: bridge

13
frontend/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
ARG VITE_HOTEL_NAME
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html/tasks
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

18
frontend/index.html Normal file
View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Tasks" />
<link rel="apple-touch-icon" href="/tasks/icons/icon-192.png" />
<meta name="theme-color" content="#0d9488" />
<title>Tasks</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

40
frontend/nginx.conf Normal file
View file

@ -0,0 +1,40 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
client_max_body_size 12m;
location /tasks/api/auth/ {
proxy_pass http://10.10.10.101:3001/api/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /tasks/api/ {
proxy_pass http://backend:3001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Cache-Control "no-store";
}
location /tasks/health {
proxy_pass http://backend:3001/health;
}
location ~* /tasks/.*\.(js|css|png|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location /tasks/ {
add_header Cache-Control "no-cache" always;
try_files $uri /tasks/index.html;
}
location = / {
return 301 /tasks/;
}
}

6272
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
frontend/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "hnf-tasks-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.5",
"vite-plugin-pwa": "^1.3.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

40
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,40 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import AuthGate from './components/AuthGate'
import { UpdateBanner } from './components/UpdateBanner'
import IosInstallHint from './components/IosInstallHint'
import { useVersionCheck } from './hooks/useVersionCheck'
import Layout from './components/Layout'
import { ListsProvider } from './context/ListsContext'
import { TaskDrawerProvider } from './components/TaskDrawer'
import TodayView from './pages/TodayView'
import ListView from './pages/ListView'
import BoardView from './pages/BoardView'
import Settings from './pages/Settings'
export default function App() {
const updateAvailable = useVersionCheck('/tasks/health')
return (
<>
<BrowserRouter basename="/tasks">
<AuthGate>
<ListsProvider>
<TaskDrawerProvider>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/today" replace />} />
<Route path="/today" element={<TodayView />} />
<Route path="/board" element={<BoardView />} />
<Route path="/list/:id" element={<ListView />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/today" replace />} />
</Routes>
</Layout>
</TaskDrawerProvider>
</ListsProvider>
</AuthGate>
</BrowserRouter>
<UpdateBanner visible={updateAvailable} />
<IosInstallHint />
</>
)
}

82
frontend/src/api.ts Normal file
View file

@ -0,0 +1,82 @@
import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig } from './types'
const BASE = '/tasks/api'
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
if (res.status === 401) {
;(window.top ?? window).location.href = '/login'
throw new Error('Unauthenticated')
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Request failed: ${res.status}`)
}
if (res.status === 204) return undefined as T
return res.json()
}
// ── Lists ────────────────────────────────────────────────────
export function fetchLists(includeArchived = false): Promise<List[]> {
return request(`/lists${includeArchived ? '?archived=1' : ''}`)
}
export function createList(body: { name: string; colour?: string; icon?: string }): Promise<List> {
return request('/lists', { method: 'POST', body: JSON.stringify(body) })
}
export function updateList(id: number, body: Partial<List>): Promise<List> {
return request(`/lists/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteList(id: number): Promise<{ ok: boolean }> {
return request(`/lists/${id}`, { method: 'DELETE' })
}
// ── Tasks ────────────────────────────────────────────────────
export function fetchTasks(params: { list_id?: number; view?: 'today'; status?: string } = {}): Promise<Task[]> {
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) if (v !== undefined) qs.set(k, String(v))
const s = qs.toString()
return request(`/tasks${s ? `?${s}` : ''}`)
}
export function fetchTask(id: number): Promise<TaskDetail> {
return request(`/tasks/${id}`)
}
export function createTask(body: Record<string, unknown>): Promise<Task> {
return request('/tasks', { method: 'POST', body: JSON.stringify(body) })
}
export function updateTask(id: number, body: Record<string, unknown>): Promise<Task> {
return request(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteTask(id: number): Promise<{ ok: boolean }> {
return request(`/tasks/${id}`, { method: 'DELETE' })
}
export function reorderTasks(ids: number[]): Promise<{ ok: boolean }> {
return request('/tasks/reorder', { method: 'POST', body: JSON.stringify({ ids }) })
}
// ── Checklist items ──────────────────────────────────────────
export function addItem(taskId: number, label: string): Promise<TaskItem> {
return request(`/tasks/${taskId}/items`, { method: 'POST', body: JSON.stringify({ label }) })
}
export function updateItem(itemId: number, body: Partial<TaskItem>): Promise<TaskItem> {
return request(`/tasks/items/${itemId}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteItem(itemId: number): Promise<{ ok: boolean }> {
return request(`/tasks/items/${itemId}`, { method: 'DELETE' })
}
// ── Comments ─────────────────────────────────────────────────
export function addComment(taskId: number, body: string): Promise<TaskComment> {
return request(`/tasks/${taskId}/comments`, { method: 'POST', body: JSON.stringify({ body }) })
}
// ── Config ───────────────────────────────────────────────────
export function fetchConfig(): Promise<AppConfig> {
return request('/config')
}
export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> {
return request(`/config/${key}`, { method: 'PUT', body: JSON.stringify({ value }) })
}

View file

@ -0,0 +1,178 @@
import { useEffect, useRef, useState, createContext, useContext } from 'react'
import type { User } from '../types'
import { usePushSubscription } from '../hooks/usePushSubscription'
function getInactivityMs(): number | null {
if (window.matchMedia('(display-mode: standalone)').matches) return null
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins='))
if (!c) return null
const mins = parseInt(c.split('=')[1])
return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000
}
// Only bounce to the central login when actually embedded in the portal shell.
// A standalone PWA or a directly-opened browser tab must never navigate away
// from its own start_url/scope — otherwise it loses its installed-app context.
function isEmbedded() {
return window.top !== window
}
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
return ctx
}
function PushSubscriber({ user }: { user: User }) {
usePushSubscription(user)
return null
}
export default function AuthGate({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
const [user, setUser] = useState<User | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
fetch('/api/auth/verify?app=tasks', { credentials: 'include' })
.then(async r => {
if (r.ok) {
setUser(await r.json())
setState('authed')
} else if (isEmbedded()) {
window.top!.location.href = `/login?from=${encodeURIComponent('/app/tasks')}`
} else {
setState('login')
}
})
.catch(() => { if (!isEmbedded()) setState('login') })
}, [])
// Inactivity auto-logout — disabled for installed PWAs; configurable per
// device (Admin Settings → Device) for shared/front-desk browser sessions.
useEffect(() => {
const ms = getInactivityMs()
if (state !== 'authed' || !ms) return
const timeoutMs: number = ms
async function forceLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
setUser(null)
setState('login')
}
function reset() {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(forceLogout, timeoutMs)
}
const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const
events.forEach(e => window.addEventListener(e, reset, { passive: true }))
reset()
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
events.forEach(e => window.removeEventListener(e, reset))
}
}, [state])
async function login(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await fetch('/api/auth/login', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) { setError('Invalid email or password'); return }
const verify = await fetch('/api/auth/verify?app=tasks', { credentials: 'include' })
if (verify.ok) {
setUser(await verify.json())
setState('authed')
} else {
setError("You don't have access to this app.")
}
} catch {
setError('Connection error — please try again')
} finally {
setLoading(false)
}
}
if (state === 'checking') {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
}}>
Loading…
</div>
)
}
if (state === 'login') {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
background: 'var(--navy-dark)',
}}>
<div style={{
background: 'var(--navy)', borderRadius: 'var(--radius)',
padding: '2rem', width: '100%', maxWidth: '360px',
border: '1px solid var(--surface-2)',
}}>
<h1 style={{ fontSize: '1.4rem', marginBottom: '0.25rem', color: 'var(--gold)' }}>
Tasks
</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: '1.5rem' }}>
{import.meta.env.VITE_HOTEL_NAME}
</p>
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<input
type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required autoComplete="email"
style={inputStyle}
/>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required autoComplete="current-password"
style={inputStyle}
/>
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
<button type="submit" disabled={loading} style={{
background: loading ? 'var(--surface-2)' : 'var(--gold)',
color: loading ? 'var(--text-muted)' : 'var(--navy-dark)',
border: 'none', borderRadius: '6px', padding: '0.625rem',
fontSize: '1rem', fontWeight: 600, marginTop: '0.25rem',
}}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}
return (
<Ctx.Provider value={{ user: user! }}>
<PushSubscriber user={user!} />
{children}
</Ctx.Provider>
)
}
const inputStyle: React.CSSProperties = {
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem',
fontSize: '1rem', width: '100%', outline: 'none',
}

View file

@ -0,0 +1,89 @@
import { useEffect, useState } from 'react'
import { Share, ExternalLink, X } from 'lucide-react'
function isIos() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
// iPadOS 13+ reports as 'MacIntel' but has touch support, unlike a real Mac
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
}
function isStandalone() {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone === true
}
// Safari's UA also matches "Safari", so this checks for the other iOS
// browsers/in-app webviews that spoof it — none of them can add to the
// home screen; only Safari itself can.
function isNonSafariIosBrowser() {
return /CriOS|FxiOS|EdgiOS|OPiOS|mercury|GSA|DuckDuckGo|Instagram|FBAN|FBAV|Line\//.test(navigator.userAgent)
}
/**
* iOS has no `beforeinstallprompt` API — Safari never fires it, so the
* portal's Install button (?install=1) does nothing there. This shows the
* manual steps instead: Share -> Add to Home Screen in Safari, or a prompt
* to switch to Safari first if the page was opened in another browser/app.
*/
export default function IosInstallHint() {
const [mode, setMode] = useState<'safari' | 'other' | null>(null)
useEffect(() => {
if (!isIos() || isStandalone()) return
if (!new URLSearchParams(window.location.search).has('install')) return
setMode(isNonSafariIosBrowser() ? 'other' : 'safari')
}, [])
if (!mode) return null
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(15,15,32,0.55)',
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
padding: '0 16px 24px',
}}>
<div style={{
position: 'relative',
width: '100%', maxWidth: '360px',
background: 'var(--card-bg)', borderRadius: 'var(--radius)',
border: '1px solid var(--card-border)', boxShadow: 'var(--shadow-md)',
padding: '20px', textAlign: 'center',
}}>
<button
onClick={() => setMode(null)}
aria-label="Dismiss"
style={{
position: 'absolute', top: '10px', right: '10px',
background: 'none', border: 'none', color: 'var(--text-mid)',
padding: '4px', cursor: 'pointer', lineHeight: 0,
}}
>
<X size={16} strokeWidth={1.75} />
</button>
<div style={{
width: '44px', height: '44px', margin: '0 auto 12px',
borderRadius: '50%', background: 'var(--gold)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{mode === 'safari'
? <Share size={22} strokeWidth={1.75} color="var(--navy)" />
: <ExternalLink size={22} strokeWidth={1.75} color="var(--navy)" />}
</div>
{mode === 'safari' ? (
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
To install this app, tap the <strong>Share</strong> icon in Safari's
toolbar, then choose <strong>Add to Home Screen</strong>.
</p>
) : (
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
iOS only allows installing apps from <strong>Safari</strong>. Open this
page in Safari, then tap Share → <strong>Add to Home Screen</strong>.
</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,106 @@
import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { ListChecks, CalendarDays, Columns3, Settings, Menu, LogOut, Plus } from 'lucide-react'
import { useAuth } from './AuthGate'
import { useLists } from '../context/ListsContext'
import { can } from '../types'
import type { List } from '../types'
import ListEditor from './ListEditor'
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
export default function Layout({ children }: { children: React.ReactNode }) {
const { user } = useAuth()
const { lists } = useLists()
const location = useLocation()
const [menuOpen, setMenuOpen] = useState(false)
const [editing, setEditing] = useState<null | 'new' | List>(null)
async function logout() {
await fetch('/tasks/api/auth/logout', { method: 'POST', credentials: 'include' })
window.location.reload()
}
useEffect(() => { setMenuOpen(false) }, [location.pathname])
return (
<div className={`app-shell${menuOpen ? ' menu-open' : ''}`}>
<aside className="sidebar">
<div className="sidebar-logo">
<ListChecks size={18} strokeWidth={1.75} />
Tasks
</div>
<nav className="sidebar-nav">
<NavLink to="/today" className={({ isActive }) => isActive ? 'active' : ''}>
<CalendarDays {...ICON_PROPS} /> Today
</NavLink>
<NavLink to="/board" className={({ isActive }) => isActive ? 'active' : ''}>
<Columns3 {...ICON_PROPS} /> Board
</NavLink>
<div className="list-nav">
<div className="list-nav-head">
Lists
<button className="list-nav-add" onClick={() => setEditing('new')} title="New list">
<Plus size={15} strokeWidth={2} />
</button>
</div>
{lists.map(l => (
<NavLink key={l.id} to={`/list/${l.id}`} className={({ isActive }) => isActive ? 'active' : ''}>
<span className="list-dot" style={{ background: l.colour }} />
{l.name}
{l.open_count > 0 && <span className="list-count">{l.open_count}</span>}
</NavLink>
))}
{lists.length === 0 && (
<div style={{ padding: '4px 16px', fontSize: 12, color: 'var(--text-muted)' }}>
No lists yet
</div>
)}
</div>
{can(user, 'settings') && (
<NavLink to="/settings" className={({ isActive }) => isActive ? 'active' : ''} style={{ marginTop: 4 }}>
<Settings {...ICON_PROPS} /> Settings
</NavLink>
)}
</nav>
<div className="sidebar-user" style={{ whiteSpace: 'normal' }}>
<div style={{ fontWeight: 600, color: 'var(--text)', fontSize: '12px', marginBottom: '2px' }}>{user.name}</div>
<div style={{ fontSize: '11px', marginBottom: '8px' }}>{user.email}</div>
<button onClick={logout} style={{
display: 'flex', alignItems: 'center', gap: '6px',
background: 'none', border: 'none', color: 'inherit',
fontSize: '12px', padding: 0, cursor: 'pointer',
}}>
<LogOut size={13} strokeWidth={1.75} />
Sign out
</button>
</div>
</aside>
{menuOpen && <div className="menu-backdrop" onClick={() => setMenuOpen(false)} />}
<header className="top-bar">
<button className="top-bar-burger" onClick={() => setMenuOpen(o => !o)}>
<Menu size={20} strokeWidth={1.75} />
</button>
<ListChecks size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">Tasks</span>
</header>
<main className="page-content">
{children}
</main>
{editing && (
<ListEditor
list={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
/>
)}
</div>
)
}

View file

@ -0,0 +1,98 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { X, Trash2 } from 'lucide-react'
import type { List } from '../types'
import { LIST_COLOURS } from '../types'
import { createList, updateList, deleteList } from '../api'
import { useLists } from '../context/ListsContext'
export default function ListEditor({ list, onClose }: { list: List | null; onClose: () => void }) {
const { reload } = useLists()
const navigate = useNavigate()
const [name, setName] = useState(list?.name ?? '')
const [colour, setColour] = useState(list?.colour ?? LIST_COLOURS[0])
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function save() {
if (!name.trim()) return
setBusy(true); setError(null)
try {
if (list) {
await updateList(list.id, { name: name.trim(), colour })
} else {
const created = await createList({ name: name.trim(), colour })
await reload()
navigate(`/list/${created.id}`)
onClose()
return
}
await reload()
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed')
setBusy(false)
}
}
async function remove() {
if (!list) return
if (!confirm(`Delete "${list.name}" and all its tasks? This cannot be undone.`)) return
setBusy(true)
try {
await deleteList(list.id)
await reload()
navigate('/today')
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'Delete failed')
setBusy(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{list ? 'Edit list' : 'New list'}</h2>
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="field">
<label>Name</label>
<input type="text" value={name} autoFocus
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') save() }}
placeholder="e.g. Handover, Projects, Waiting on" />
</div>
<div className="field">
<label>Colour</label>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{LIST_COLOURS.map(c => (
<button key={c} onClick={() => setColour(c)} title={c}
style={{
width: 26, height: 26, borderRadius: '50%', background: c, cursor: 'pointer',
border: colour === c ? '3px solid var(--text-dark)' : '3px solid transparent',
}} />
))}
</div>
</div>
<div className="modal-footer-actions">
{list && (
<button className="btn-ghost-sm btn-ghost-danger" onClick={remove} disabled={busy} style={{ marginRight: 'auto' }}>
<Trash2 size={14} strokeWidth={1.75} /> Delete
</button>
)}
<button className="btn" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={save} disabled={busy || !name.trim()}>
{list ? 'Save' : 'Create'}
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,214 @@
import { useEffect, useState } from 'react'
import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput } from 'lucide-react'
import type { TaskDetail as TDetail, Status, Priority, RecurRule } from '../types'
import { PRIORITIES, PRIORITY_LABELS, STATUSES, STATUS_LABELS, RECUR_RULES, RECUR_LABELS } from '../types'
import {
fetchTask, updateTask, deleteTask,
addItem, updateItem, deleteItem, addComment,
} from '../api'
import { useLists } from '../context/ListsContext'
import { Check_, formatDateTime, toLocalInput } from './shared'
export default function TaskDetail({ taskId, onChange, onClose }:
{ taskId: number; onChange: () => void; onClose: () => void }) {
const { lists } = useLists()
const [task, setTask] = useState<TDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [title, setTitle] = useState('')
const [notes, setNotes] = useState('')
const [newItem, setNewItem] = useState('')
const [comment, setComment] = useState('')
useEffect(() => {
fetchTask(taskId).then(t => { setTask(t); setTitle(t.title); setNotes(t.notes ?? '') })
.catch(e => setError(e.message))
}, [taskId])
if (error) return (
<Shell onClose={onClose}><div className="error-banner">{error}</div></Shell>
)
if (!task) return <Shell onClose={onClose}><div className="empty-state">Loading…</div></Shell>
// Patch task fields, merge the result locally, and notify the parent view.
async function patch(fields: Record<string, unknown>) {
const updated = await updateTask(taskId, fields)
setTask(t => t ? { ...t, ...updated } : t)
onChange()
}
const toLocalIso = (v: string) => v ? new Date(v).toISOString() : ''
async function toggleDone() {
await patch({ status: task!.status === 'done' ? 'todo' : 'done' })
}
async function addChecklistItem() {
if (!newItem.trim()) return
const item = await addItem(taskId, newItem.trim())
setTask(t => t ? { ...t, items: [...t.items, item] } : t)
setNewItem('')
onChange()
}
async function toggleItem(id: number, is_done: boolean) {
await updateItem(id, { is_done })
setTask(t => t ? { ...t, items: t.items.map(i => i.id === id ? { ...i, is_done } : i) } : t)
}
async function removeItem(id: number) {
await deleteItem(id)
setTask(t => t ? { ...t, items: t.items.filter(i => i.id !== id) } : t)
}
async function postComment() {
if (!comment.trim()) return
const c = await addComment(taskId, comment.trim())
setTask(t => t ? { ...t, comments: [...t.comments, c] } : t)
setComment('')
}
async function removeTask() {
if (!confirm('Delete this task?')) return
await deleteTask(taskId)
onChange()
onClose()
}
const doneItems = task.items.filter(i => i.is_done).length
return (
<Shell onClose={onClose} onDelete={removeTask}>
{/* Title + completion */}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', marginBottom: 14 }}>
<div style={{ marginTop: 4 }}><Check_ status={task.status} onClick={toggleDone} /></div>
<textarea
value={title}
onChange={e => setTitle(e.target.value)}
onBlur={() => title.trim() && title !== task.title && patch({ title: title.trim() })}
rows={1}
style={{
flex: 1, border: 'none', outline: 'none', background: 'none', resize: 'none',
fontSize: 17, fontWeight: 600, fontFamily: 'var(--font)', color: 'var(--text-dark)',
}}
/>
</div>
{/* Status pills */}
<div className="chip-bar">
{STATUSES.map(s => (
<button key={s} className={`chip${task.status === s ? ' active' : ''}`} onClick={() => patch({ status: s })}>
{STATUS_LABELS[s]}
</button>
))}
</div>
{/* Attributes */}
<div className="card" style={{ padding: '12px 14px' }}>
<div className="field" style={{ marginBottom: 10 }}>
<label><FolderInput size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> List</label>
<select value={task.list_id} onChange={e => patch({ list_id: Number(e.target.value) })}>
{lists.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</div>
<div className="field-row">
<div className="field">
<label>Priority</label>
<select value={task.priority} onChange={e => patch({ priority: e.target.value as Priority })}>
{PRIORITIES.map(p => <option key={p} value={p}>{PRIORITY_LABELS[p]}</option>)}
</select>
</div>
<div className="field">
<label><Repeat size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Repeat</label>
<select value={task.recur_rule ?? ''} onChange={e => patch({ recur_rule: e.target.value || null })}>
<option value="">Doesn't repeat</option>
{RECUR_RULES.map(r => <option key={r} value={r}>{RECUR_LABELS[r as RecurRule]}</option>)}
</select>
</div>
</div>
<div className="field-row">
<div className="field">
<label><CalendarClock size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Due</label>
<input type="datetime-local" value={toLocalInput(task.due_at)}
onChange={e => patch({ due_at: toLocalIso(e.target.value) })} />
</div>
<div className="field">
<label><Bell size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Remind</label>
<input type="datetime-local" value={toLocalInput(task.remind_at)}
onChange={e => patch({ remind_at: toLocalIso(e.target.value) })} />
</div>
</div>
</div>
{/* Notes */}
<div className="section-title">Notes</div>
<div className="field">
<textarea value={notes} placeholder="Add notes…"
onChange={e => setNotes(e.target.value)}
onBlur={() => notes !== (task.notes ?? '') && patch({ notes })} />
</div>
{/* Checklist */}
<div className="section-title">Checklist {task.items.length > 0 && `(${doneItems}/${task.items.length})`}</div>
{task.items.length > 0 && (
<div className="checklist-progress">
<div style={{ width: `${(doneItems / task.items.length) * 100}%` }} />
</div>
)}
{task.items.map(i => (
<div key={i.id} className={`checklist-item${i.is_done ? ' done' : ''}`}>
<button className={`check${i.is_done ? ' checked' : ''}`} onClick={() => toggleItem(i.id, !i.is_done)} type="button" />
<span>{i.label}</span>
<button className="checklist-del" onClick={() => removeItem(i.id)}><X size={14} strokeWidth={1.75} /></button>
</div>
))}
<div className="task-add" style={{ marginTop: 8 }}>
<Plus size={16} strokeWidth={1.75} color="var(--text-mid)" />
<input value={newItem} placeholder="Add a sub-task"
onChange={e => setNewItem(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addChecklistItem() }} />
</div>
{/* Comments / activity */}
<div className="section-title">Activity</div>
<div className="timeline">
{task.comments.map(c => (
<div key={c.id} className="timeline-item">
<div className="timeline-body">
<div className="timeline-note">{c.body}</div>
<div className="timeline-meta">{c.author_name || 'You'} · {formatDateTime(c.created_at)}</div>
</div>
</div>
))}
{task.comments.length === 0 && <div className="muted" style={{ fontSize: 12 }}>No activity yet.</div>}
</div>
<div className="task-add" style={{ marginTop: 8 }}>
<input value={comment} placeholder="Add a note…"
onChange={e => setComment(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') postComment() }} />
<button className="btn btn-sm btn-primary" onClick={postComment} disabled={!comment.trim()}>Post</button>
</div>
<div className="timeline-meta" style={{ marginTop: 14 }}>
Created {formatDateTime(task.created_at)}
{task.completed_at && ` · Completed ${formatDateTime(task.completed_at)}`}
</div>
</Shell>
)
}
function Shell({ children, onClose, onDelete }:
{ children: React.ReactNode; onClose: () => void; onDelete?: () => void }) {
return (
<>
<div className="drawer-overlay" onClick={onClose} />
<div className="drawer">
<div className="drawer-head">
<h2 style={{ visibility: 'hidden' }}>Task</h2>
{onDelete && (
<button className="btn-ghost-sm btn-ghost-danger" onClick={onDelete}>
<Trash2 size={15} strokeWidth={1.75} />
</button>
)}
<button className="modal-close" onClick={onClose}><X size={20} strokeWidth={1.75} /></button>
</div>
<div className="drawer-body">{children}</div>
</div>
</>
)
}

View file

@ -0,0 +1,44 @@
import { createContext, useContext, useState, useCallback } from 'react'
import { useLists } from '../context/ListsContext'
import TaskDetail from './TaskDetail'
interface DrawerCtx {
openTask: (id: number) => void
changeVersion: number // bumps whenever a task is mutated via the drawer
}
const Ctx = createContext<DrawerCtx | null>(null)
export function useTaskDrawer() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useTaskDrawer must be used inside TaskDrawerProvider')
return ctx
}
export function TaskDrawerProvider({ children }: { children: React.ReactNode }) {
const { reload: reloadLists } = useLists()
const [openId, setOpenId] = useState<number | null>(null)
const [changeVersion, setChangeVersion] = useState(0)
const openTask = useCallback((id: number) => setOpenId(id), [])
// Called by the drawer whenever it edits a task: refresh sidebar counts and
// signal open views to reload.
const notifyChange = useCallback(() => {
setChangeVersion(v => v + 1)
reloadLists()
}, [reloadLists])
return (
<Ctx.Provider value={{ openTask, changeVersion }}>
{children}
{openId !== null && (
<TaskDetail
taskId={openId}
onChange={notifyChange}
onClose={() => setOpenId(null)}
/>
)}
</Ctx.Provider>
)
}

View file

@ -0,0 +1,61 @@
import { GripVertical, Bell, Repeat, ListChecks } from 'lucide-react'
import type { Task } from '../types'
import { Check_, PriorityBadge, dueInfo } from './shared'
interface Props {
task: Task
onToggle: (t: Task) => void
onOpen: (t: Task) => void
showList?: boolean
draggable?: boolean
dragging?: boolean
onDragStart?: () => void
onDragOver?: (e: React.DragEvent) => void
onDrop?: () => void
}
export default function TaskRow({
task, onToggle, onOpen, showList,
draggable, dragging, onDragStart, onDragOver, onDrop,
}: Props) {
const due = dueInfo(task.due_at)
return (
<div
className={`task-row ${task.priority === 'high' ? 'high' : ''} ${task.status === 'done' ? 'done' : ''} ${dragging ? 'dragging' : ''}`}
onClick={() => onOpen(task)}
draggable={draggable}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
>
{draggable && (
<span className="task-row-drag" onClick={e => e.stopPropagation()}>
<GripVertical size={16} strokeWidth={1.75} />
</span>
)}
<div onClick={e => { e.stopPropagation(); onToggle(task) }}>
<Check_ status={task.status} onClick={() => {}} />
</div>
<div className="task-row-main">
<div className="task-row-title">{task.title}</div>
<div className="task-row-meta">
{showList && task.list_name && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span className="list-dot" style={{ background: task.list_colour, width: 8, height: 8 }} />
{task.list_name}
</span>
)}
{due && <span className={`due-chip${due.overdue ? ' overdue' : ''}`}>{due.label}</span>}
{task.remind_at && <Bell size={12} strokeWidth={1.75} />}
{task.recur_rule && <Repeat size={12} strokeWidth={1.75} />}
{!!task.item_count && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
<ListChecks size={12} strokeWidth={1.75} />{task.item_done}/{task.item_count}
</span>
)}
</div>
</div>
<PriorityBadge priority={task.priority} />
</div>
)
}

View file

@ -0,0 +1,44 @@
import { RefreshCw } from 'lucide-react'
export function UpdateBanner({ visible }: { visible: boolean }) {
if (!visible) return null
return (
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 9999,
background: 'var(--sidebar)',
color: 'var(--text-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
padding: '10px 16px',
fontSize: '14px',
boxShadow: '0 -2px 8px rgba(0,0,0,0.3)',
}}>
<span>A new version is available.</span>
<button
onClick={() => window.location.reload()}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
background: 'var(--accent)',
color: 'var(--sidebar)',
border: 'none',
borderRadius: '4px',
padding: '6px 14px',
fontWeight: 600,
cursor: 'pointer',
fontSize: '13px',
}}
>
<RefreshCw size={14} strokeWidth={1.75} />
Reload
</button>
</div>
)
}

View file

@ -0,0 +1,65 @@
import { Check } from 'lucide-react'
import type { Priority, Status } from '../types'
import { PRIORITY_LABELS, STATUS_LABELS } from '../types'
export function PriorityBadge({ priority }: { priority: Priority }) {
if (priority === 'medium') return null
return <span className={`badge badge-prio-${priority}`}>{PRIORITY_LABELS[priority]}</span>
}
export function StatusBadge({ status }: { status: Status }) {
return <span className={`badge badge-st-${status}`}>{STATUS_LABELS[status]}</span>
}
// Round completion checkbox. 'doing' shows a partial ring, 'done' a filled tick.
export function Check_({ status, onClick }: { status: Status; onClick: (e: React.MouseEvent) => void }) {
const cls = status === 'done' ? 'check checked' : status === 'doing' ? 'check doing' : 'check'
return (
<button className={cls} onClick={onClick} aria-label="toggle done" type="button">
{status === 'done' && <Check size={13} strokeWidth={3} />}
{status === 'doing' && <span style={{ width: 8, height: 8, borderRadius: '50%', background: 'currentColor' }} />}
</button>
)
}
export function formatDate(iso: string | null | undefined): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
}
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + ' ' +
d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
}
function startOfDay(d: Date) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
// Human relative due label + whether it's overdue.
export function dueInfo(iso: string | null): { label: string; overdue: boolean } | null {
if (!iso) return null
const due = new Date(iso)
const today = startOfDay(new Date())
const dueDay = startOfDay(due)
const days = Math.round((dueDay.getTime() - today.getTime()) / 86400000)
const overdue = days < 0
let label: string
if (days === 0) label = 'Today'
else if (days === 1) label = 'Tomorrow'
else if (days === -1) label = 'Yesterday'
else if (days < 0) label = `${-days} days ago`
else if (days < 7) label = due.toLocaleDateString('en-GB', { weekday: 'short' })
else label = due.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
const hasTime = due.getHours() !== 0 || due.getMinutes() !== 0
if (hasTime) label += ' ' + due.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
return { label, overdue }
}
// Convert an ISO timestamp to the value a datetime-local input expects.
export function toLocalInput(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}

View file

@ -0,0 +1,32 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import type { List } from '../types'
import { fetchLists } from '../api'
interface ListsCtx {
lists: List[]
loading: boolean
reload: () => Promise<void>
}
const Ctx = createContext<ListsCtx | null>(null)
export function useLists() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useLists must be used inside ListsProvider')
return ctx
}
export function ListsProvider({ children }: { children: React.ReactNode }) {
const [lists, setLists] = useState<List[]>([])
const [loading, setLoading] = useState(true)
const reload = useCallback(async () => {
const data = await fetchLists().catch(() => [])
setLists(data)
setLoading(false)
}, [])
useEffect(() => { reload() }, [reload])
return <Ctx.Provider value={{ lists, loading, reload }}>{children}</Ctx.Provider>
}

View file

@ -0,0 +1,57 @@
import { useEffect } from 'react'
import type { User } from '../types'
const BASE = '/tasks/api'
function urlBase64ToUint8Array(base64: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(b64)
const buf = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i)
return buf.buffer
}
export function usePushSubscription(user: User) {
useEffect(() => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
if (Notification.permission === 'denied') return
async function subscribe() {
try {
const reg = await navigator.serviceWorker.ready
const keyRes = await fetch(`${BASE}/push/vapid-key`, { credentials: 'include' })
if (!keyRes.ok) return
const { publicKey } = await keyRes.json() as { publicKey: string }
let sub = await reg.pushManager.getSubscription()
if (!sub) {
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission()
if (permission !== 'granted') return
} else if (Notification.permission !== 'granted') {
return
}
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
}
// Always re-POST so subscription is tied to current user (handles re-login)
await fetch(`${BASE}/push/subscribe`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub.toJSON()),
})
} catch (err) {
console.error('[push] subscription failed:', err)
}
}
subscribe()
}, [user.email])
}

View file

@ -0,0 +1,43 @@
import { useEffect, useState } from 'react'
const POLL_MS = 2 * 60 * 1000
export function useVersionCheck(healthUrl: string) {
const [updateAvailable, setUpdateAvailable] = useState(false)
useEffect(() => {
let seenVersion: string | null = null
async function check() {
try {
const res = await fetch(healthUrl, { cache: 'no-store' })
if (!res.ok) return
const data = await res.json()
const v: string | undefined = data.version
if (!v) return
if (seenVersion === null) {
seenVersion = v
} else if (v !== seenVersion) {
setUpdateAvailable(true)
}
} catch {
// network error — skip silently
}
}
check()
const interval = setInterval(check, POLL_MS)
function onVisible() {
if (document.visibilityState === 'visible') check()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
clearInterval(interval)
document.removeEventListener('visibilitychange', onVisible)
}
}, [healthUrl])
return updateAvailable
}

563
frontend/src/index.css Normal file
View file

@ -0,0 +1,563 @@
/* Stack design system tokens — include verbatim in every app */
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--surface: rgba(255,255,255,0.07);
--surface-2: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.48);
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--card-border: #e4e8ee;
--text-dark: #1e293b;
--text-mid: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--danger: #dc2626;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body { background: var(--body-bg); color: var(--text-dark); font-family: var(--font); }
/* App theme + semantic tokens */
:root {
--app-primary: #0d9488;
--app-primary-light: #14b8a6;
--prio-low: #64748b;
--prio-medium: #2563eb;
--prio-high: #dc2626;
--st-todo: #64748b;
--st-doing: #2563eb;
--st-done: #16a34a;
--danger-bg: #fef2f2;
--warn-bg: #fffbeb;
--ok-bg: #f0fdf4;
--sidebar-w: 240px;
--topbar-h: 56px;
}
*, *::before, *::after { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; font-size: 14px; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 2px; }
/* ── App shell ─────────────────────────────────────────────── */
.app-shell { display: flex; height: 100vh; overflow: hidden; }
.sidebar {
width: var(--sidebar-w);
background: var(--navy);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow-y: auto;
}
.sidebar-logo {
padding: 20px 16px 12px;
color: var(--gold);
font-size: 13px;
font-weight: 600;
letter-spacing: .05em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-logo svg { opacity: .8; }
.sidebar-nav { flex: 1; padding: 8px 0; }
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: background .15s, color .15s;
}
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
.sidebar-user {
padding: 12px 16px;
border-top: 1px solid var(--surface-2);
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-bar {
display: none;
height: var(--topbar-h);
background: var(--navy);
color: var(--text);
align-items: center;
padding: 0 12px;
gap: 10px;
flex-shrink: 0;
}
.top-bar-title { flex: 1; font-size: 15px; font-weight: 600; color: var(--gold); }
.top-bar-nav { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; }
.top-bar-nav::-webkit-scrollbar { display: none; }
.top-bar-nav a {
color: var(--text-muted);
padding: 6px 8px;
border-radius: 6px;
text-decoration: none;
font-size: 12px;
white-space: nowrap;
}
.top-bar-nav a.active { color: var(--gold); }
.page-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 200;
transform: translateX(calc(-1 * var(--sidebar-w)));
transition: transform 0.25s ease;
}
.app-shell.menu-open .sidebar { transform: translateX(0); }
.top-bar { display: flex; }
.app-shell { flex-direction: column; }
.field-row { flex-direction: column; }
}
.menu-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 199;
}
.top-bar-burger {
background: none;
border: none;
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
padding: 4px;
flex-shrink: 0;
}
/* ── Page chrome ───────────────────────────────────────────── */
.page { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; }
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.page-header h1 { font-size: 18px; margin: 0; flex: 1; }
/* ── Buttons ───────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--card-border);
background: var(--card-bg);
color: var(--text-dark);
border-radius: var(--radius);
padding: 7px 14px;
font-size: 13px;
cursor: pointer;
font-family: var(--font);
transition: background .12s, border-color .12s;
}
.btn:hover { border-color: var(--text-mid); }
.btn:disabled { opacity: .5; cursor: default; }
.btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
.btn-primary:hover { background: var(--gold-light); border-color: var(--gold-light); }
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 8px; }
/* ── Forms ─────────────────────────────────────────────────── */
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; }
.field input[type="text"], .field input[type="email"], .field input[type="date"],
.field input[type="number"], .field select, .field textarea {
width: 100%;
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13.5px;
font-family: var(--font);
color: var(--text-dark);
background: var(--card-bg);
}
.field textarea { min-height: 72px; resize: vertical; }
.field-row { display: flex; gap: 12px; }
.field-row > .field { flex: 1; }
.field-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; }
.field-check input { width: 16px; height: 16px; accent-color: var(--gold); }
.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; }
/* ── Cards & lists ─────────────────────────────────────────── */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 14px 16px;
margin-bottom: 10px;
}
.task-card {
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
transition: box-shadow .12s;
}
.task-card:hover { box-shadow: var(--shadow-md); }
.task-card.high { border-left: 4px solid var(--prio-high); }
.task-card.done { opacity: .55; }
.task-card-main { flex: 1; min-width: 0; }
.task-card-title { font-weight: 600; font-size: 14px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.task-card-meta { font-size: 12px; color: var(--text-mid); display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.task-card-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; }
/* ── Badges ────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 20px;
padding: 2px 9px;
font-size: 11px;
font-weight: 600;
color: #fff;
white-space: nowrap;
}
.badge-prio-low { background: var(--prio-low); }
.badge-prio-medium { background: var(--prio-medium); }
.badge-prio-high { background: var(--prio-high); }
.badge-st-todo { background: var(--st-todo); }
.badge-st-doing { background: var(--st-doing); }
.badge-st-done { background: var(--st-done); }
.badge-outline {
background: transparent;
border: 1px solid var(--card-border);
color: var(--text-mid);
font-weight: 500;
}
.badge-unusable { background: var(--danger); }
/* ── Filter chips ──────────────────────────────────────────── */
.filter-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 4px; }
.filter-row .filter-section { flex: 1; min-width: 0; margin-bottom: 0; }
.filter-row .sort-chip { flex-shrink: 0; align-self: center; white-space: nowrap; }
.filter-section { margin-bottom: 10px; }
.filter-section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 8px;
background: var(--card-bg);
border: 1px solid var(--card-border);
cursor: pointer;
user-select: none;
font-size: 12px;
color: var(--text-mid);
transition: border-color .12s;
}
.filter-section-header:hover { border-color: var(--gold); }
.filter-section-label { font-weight: 600; color: var(--text-dark); white-space: nowrap; }
.filter-section-summary { flex: 1; color: var(--gold); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.filter-section-summary.muted { color: var(--text-mid); font-weight: 400; }
.filter-section-chevron { flex-shrink: 0; transition: transform .18s; }
.filter-section-chevron.open { transform: rotate(180deg); }
.chip-bar { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 4px; align-items: center; }
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border-radius: 20px;
border: 1px solid var(--card-border);
background: var(--card-bg);
cursor: pointer;
font-size: 12px;
color: var(--text-mid);
user-select: none;
font-family: var(--font);
transition: all .12s;
}
.chip:hover { border-color: var(--gold); color: var(--text-dark); }
.chip.active { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
/* ── Modal ─────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15,15,32,.55);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px 12px;
z-index: 100;
overflow-y: auto;
}
.modal {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
width: 100%;
max-width: 680px;
padding: 20px;
margin: auto 0;
}
.modal-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; }
.modal-header h2 { font-size: 16px; margin: 0; flex: 1; }
.modal-close {
background: none;
border: none;
cursor: pointer;
color: var(--text-mid);
padding: 2px;
display: flex;
}
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
.modal-footer-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--card-border); }
.btn-ghost-sm {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 8px; border-radius: 6px; border: none; background: none;
font-size: 12px; color: var(--text-mid); cursor: pointer; font-family: var(--font);
transition: color .12s, background .12s;
}
.btn-ghost-sm:hover { color: var(--text-dark); background: var(--card-border); }
.btn-ghost-danger { color: var(--danger); }
.btn-ghost-danger:hover { color: var(--danger); background: var(--danger-bg); }
/* ── Timeline / thread ─────────────────────────────────────── */
.timeline { margin: 8px 0; }
.timeline-item {
display: flex;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--card-border);
font-size: 13px;
}
.timeline-item:last-child { border-bottom: none; }
.timeline-icon { color: var(--text-mid); flex-shrink: 0; margin-top: 1px; }
.timeline-body { flex: 1; min-width: 0; }
.timeline-note { white-space: pre-wrap; }
.timeline-meta { font-size: 11.5px; color: var(--text-mid); margin-top: 2px; }
.timeline-photo-link { color: var(--gold); cursor: pointer; font-size: 12px; font-weight: 500; }
.timeline-photo-link:hover { text-decoration: underline; }
/* ── Photos ────────────────────────────────────────────────── */
.photo-grid { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
.photo-thumb {
width: 84px;
height: 84px;
border-radius: 8px;
object-fit: cover;
border: 1px solid var(--card-border);
cursor: pointer;
}
.photo-thumb-uploading {
display: flex; align-items: center; justify-content: center;
background: var(--card-border); color: var(--text-mid);
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin .75s linear infinite; }
.photo-thumb-wrap { position: relative; }
.photo-del {
position: absolute;
top: -6px;
right: -6px;
background: var(--danger);
color: #fff;
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
/* ── Lightbox ──────────────────────────────────────────────── */
.lightbox-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,.92);
display: flex; align-items: center; justify-content: center;
padding: 16px;
}
.lightbox-img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; }
.lightbox-close {
position: absolute; top: 16px; right: 16px;
background: rgba(255,255,255,.15); border: none; border-radius: 50%;
width: 38px; height: 38px; display: flex; align-items: center; justify-content: center;
cursor: pointer; color: #fff; transition: background .12s;
}
.lightbox-close:hover { background: rgba(255,255,255,.28); }
/* ── Tables ────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
table.data th {
text-align: left;
padding: 9px 12px;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-mid);
border-bottom: 1px solid var(--card-border);
white-space: nowrap;
}
table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border); vertical-align: top; }
table.data tr:last-child td { border-bottom: none; }
table.data tr.clickable { cursor: pointer; }
table.data tr.clickable:hover td { background: var(--body-bg); }
/* ── Stats strip ───────────────────────────────────────────── */
.stats-strip { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.stat-box {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 10px 16px;
min-width: 110px;
}
.stat-box .stat-value { font-size: 18px; font-weight: 700; }
.stat-box .stat-label { font-size: 11px; color: var(--text-mid); text-transform: uppercase; letter-spacing: .04em; }
/* ── Misc ──────────────────────────────────────────────────── */
.empty-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; }
.error-banner {
background: var(--danger-bg);
border: 1px solid var(--danger);
color: var(--danger);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
}
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; }
.muted { color: var(--text-mid); }
.overdue { color: var(--danger); font-weight: 600; }
/* Sidebar scrollbar */
.nav-scroll::-webkit-scrollbar,
.sidebar::-webkit-scrollbar,
.sidebar-nav::-webkit-scrollbar { width: 4px; }
.nav-scroll::-webkit-scrollbar-track,
.sidebar::-webkit-scrollbar-track,
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.nav-scroll::-webkit-scrollbar-thumb,
.sidebar::-webkit-scrollbar-thumb,
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; }
.nav-scroll::-webkit-scrollbar-thumb:hover,
.sidebar::-webkit-scrollbar-thumb:hover,
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
.nav-scroll, .sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }
/* ── Tasks: list nav (in sidebar) ──────────────────────────── */
.list-nav { padding: 4px 0 8px; border-top: 1px solid var(--surface-2); margin-top: 4px; }
.list-nav-head {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 16px 4px; color: var(--text-muted);
font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase;
}
.list-nav-add { background: none; border: none; color: var(--text-muted); cursor: pointer; display: flex; padding: 2px; }
.list-nav-add:hover { color: var(--gold); }
.list-nav a {
display: flex; align-items: center; gap: 10px; padding: 8px 16px;
color: var(--text-muted); text-decoration: none; font-size: 13.5px;
}
.list-nav a:hover { background: var(--surface); color: var(--text); }
.list-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
.list-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.list-nav a .list-count { margin-left: auto; font-size: 11px; color: var(--text-muted); }
/* ── Tasks: inline add + rows ──────────────────────────────── */
.task-add {
display: flex; align-items: center; gap: 8px;
background: var(--card-bg); border: 1px solid var(--card-border);
border-radius: var(--radius); box-shadow: var(--shadow-sm);
padding: 4px 10px; margin-bottom: 12px;
}
.task-add input {
flex: 1; border: none; outline: none; background: none;
font-size: 14px; font-family: var(--font); color: var(--text-dark); padding: 8px 0;
}
.task-row {
display: flex; align-items: center; gap: 12px;
background: var(--card-bg); border: 1px solid var(--card-border);
border-radius: var(--radius); box-shadow: var(--shadow-sm);
padding: 10px 14px; margin-bottom: 6px; cursor: pointer;
transition: box-shadow .12s;
}
.task-row:hover { box-shadow: var(--shadow-md); }
.task-row.high { border-left: 3px solid var(--prio-high); }
.task-row.done .task-row-title { text-decoration: line-through; color: var(--text-mid); }
.task-row-main { flex: 1; min-width: 0; }
.task-row-title { font-size: 14px; font-weight: 500; }
.task-row-meta { font-size: 11.5px; color: var(--text-mid); display: flex; gap: 10px; flex-wrap: wrap; margin-top: 2px; align-items: center; }
.task-row-drag { color: var(--card-border); cursor: grab; display: flex; }
.task-row.dragging { opacity: .4; }
/* Round checkbox */
.check {
width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0;
border: 2px solid var(--card-border); background: none; cursor: pointer;
display: flex; align-items: center; justify-content: center; color: #fff; padding: 0;
transition: background .12s, border-color .12s;
}
.check:hover { border-color: var(--st-done); }
.check.checked { background: var(--st-done); border-color: var(--st-done); }
.check.doing { border-color: var(--st-doing); color: var(--st-doing); }
.due-chip { color: var(--text-mid); }
.due-chip.overdue { color: var(--danger); font-weight: 600; }
/* ── Slide-over drawer (task detail) ───────────────────────── */
.drawer-overlay { position: fixed; inset: 0; background: rgba(15,15,32,.4); z-index: 150; }
.drawer {
position: fixed; top: 0; right: 0; bottom: 0; z-index: 151;
width: 440px; max-width: 100vw; background: var(--body-bg);
box-shadow: -4px 0 24px rgba(0,0,0,.15);
display: flex; flex-direction: column; overflow-y: auto;
animation: drawer-in .2s ease;
}
@keyframes drawer-in { from { transform: translateX(100%); } to { transform: translateX(0); } }
.drawer-head { display: flex; align-items: flex-start; gap: 10px; padding: 16px; background: var(--card-bg); border-bottom: 1px solid var(--card-border); }
.drawer-head h2 { font-size: 16px; margin: 0; flex: 1; }
.drawer-body { padding: 16px; flex: 1; }
/* Checklist */
.checklist-item { display: flex; align-items: center; gap: 10px; padding: 5px 0; }
.checklist-item .check { width: 18px; height: 18px; }
.checklist-item.done span { text-decoration: line-through; color: var(--text-mid); }
.checklist-item span { flex: 1; font-size: 13.5px; }
.checklist-del { background: none; border: none; color: var(--text-mid); cursor: pointer; display: flex; padding: 2px; }
.checklist-del:hover { color: var(--danger); }
.checklist-progress { height: 5px; background: var(--card-border); border-radius: 3px; overflow: hidden; margin: 6px 0 10px; }
.checklist-progress > div { height: 100%; background: var(--st-done); }
/* ── Board (kanban) ────────────────────────────────────────── */
.board { display: flex; gap: 12px; align-items: flex-start; overflow-x: auto; padding-bottom: 8px; }
.board-col { flex: 1; min-width: 240px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); padding: 10px; }
.board-col.drop-target { border-color: var(--gold); background: var(--warn-bg); }
.board-col-head { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
.board-col-count { margin-left: auto; font-weight: 500; }
/* Agenda groups (Today view) */
.agenda-group-title { font-size: 13px; font-weight: 700; color: var(--text-dark); margin: 16px 0 8px; display: flex; align-items: center; gap: 8px; }
.agenda-group-title.overdue { color: var(--danger); }

18
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,18 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
if (new URLSearchParams(window.location.search).has('install')) {
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault()
;(e as Event & { prompt: () => Promise<void> }).prompt()
}, { once: true })
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View file

@ -0,0 +1,78 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { Columns3 } from 'lucide-react'
import type { Task, Status } from '../types'
import { STATUSES, STATUS_LABELS } from '../types'
import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
import { PriorityBadge, dueInfo } from '../components/shared'
export default function BoardView() {
const { openTask, changeVersion } = useTaskDrawer()
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const [over, setOver] = useState<Status | null>(null)
const dragId = useRef<number | null>(null)
const load = useCallback(() => {
fetchTasks({}).then(setTasks).catch(e => setError(e.message))
}, [])
useEffect(() => { load() }, [load, changeVersion])
async function drop(status: Status) {
const id = dragId.current
dragId.current = null
setOver(null)
if (id === null) return
const task = tasks.find(t => t.id === id)
if (!task || task.status === status) return
setTasks(ts => ts.map(t => t.id === id ? { ...t, status } : t))
await updateTask(id, { status }).catch(() => load())
load()
}
return (
<div className="page" style={{ maxWidth: 1200 }}>
<div className="page-header">
<h1><Columns3 size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Board</h1>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="board">
{STATUSES.map(status => {
const items = tasks.filter(t => t.status === status)
return (
<div key={status}
className={`board-col${over === status ? ' drop-target' : ''}`}
onDragOver={e => { e.preventDefault(); setOver(status) }}
onDragLeave={() => setOver(o => o === status ? null : o)}
onDrop={() => drop(status)}>
<div className="board-col-head">
{STATUS_LABELS[status]}
<span className="board-col-count">{items.length}</span>
</div>
{items.map(t => (
<div key={t.id} className="card" style={{ cursor: 'grab', padding: '10px 12px' }}
draggable
onDragStart={() => { dragId.current = t.id }}
onClick={() => openTask(t.id)}>
<div style={{ fontSize: 13.5, fontWeight: 500, marginBottom: 4 }}>{t.title}</div>
<div className="task-row-meta">
{t.list_name && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span className="list-dot" style={{ background: t.list_colour, width: 8, height: 8 }} />
{t.list_name}
</span>
)}
{(() => { const d = dueInfo(t.due_at); return d && <span className={`due-chip${d.overdue ? ' overdue' : ''}`}>{d.label}</span> })()}
<PriorityBadge priority={t.priority} />
</div>
</div>
))}
{items.length === 0 && <div className="muted" style={{ fontSize: 12, padding: '4px 2px' }}>—</div>}
</div>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,105 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { Plus, Pencil } from 'lucide-react'
import type { Task, List } from '../types'
import { fetchTasks, createTask, updateTask, reorderTasks } from '../api'
import { useLists } from '../context/ListsContext'
import { useTaskDrawer } from '../components/TaskDrawer'
import TaskRow from '../components/TaskRow'
import ListEditor from '../components/ListEditor'
export default function ListView() {
const { id } = useParams()
const listId = Number(id)
const { lists } = useLists()
const { openTask, changeVersion } = useTaskDrawer()
const list = lists.find(l => l.id === listId)
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const [newTitle, setNewTitle] = useState('')
const [editing, setEditing] = useState(false)
const dragId = useRef<number | null>(null)
const load = useCallback(() => {
if (!listId) return
fetchTasks({ list_id: listId }).then(setTasks).catch(e => setError(e.message))
}, [listId])
useEffect(() => { load() }, [load, changeVersion])
async function add() {
if (!newTitle.trim()) return
await createTask({ list_id: listId, title: newTitle.trim() })
setNewTitle('')
load()
}
async function toggle(t: Task) {
await updateTask(t.id, { status: t.status === 'done' ? 'todo' : 'done' })
load()
}
// Native drag reorder within the open list.
function onDragStart(taskId: number) { dragId.current = taskId }
function onDrop(targetId: number) {
const from = dragId.current
dragId.current = null
if (from === null || from === targetId) return
const ids = tasks.map(t => t.id)
const fromIdx = ids.indexOf(from)
const toIdx = ids.indexOf(targetId)
ids.splice(toIdx, 0, ids.splice(fromIdx, 1)[0])
const reordered = ids.map(i => tasks.find(t => t.id === i)!).filter(Boolean)
setTasks(reordered)
reorderTasks(ids).catch(() => load())
}
const open = tasks.filter(t => t.status !== 'done')
const done = tasks.filter(t => t.status === 'done')
return (
<div className="page">
<div className="page-header">
<h1>
{list && <span className="list-dot" style={{ background: list.colour, width: 12, height: 12, display: 'inline-block', marginRight: 8, verticalAlign: 0 }} />}
{list?.name ?? 'List'}
</h1>
{list && (
<button className="btn btn-sm" onClick={() => setEditing(true)}>
<Pencil size={13} strokeWidth={1.75} /> Edit
</button>
)}
</div>
{error && <div className="error-banner">{error}</div>}
<div className="task-add">
<Plus size={18} strokeWidth={1.75} color="var(--text-mid)" />
<input value={newTitle} placeholder="Add a task…"
onChange={e => setNewTitle(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') add() }} />
</div>
{open.map(t => (
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)}
draggable dragging={dragId.current === t.id}
onDragStart={() => onDragStart(t.id)}
onDragOver={e => e.preventDefault()}
onDrop={() => onDrop(t.id)} />
))}
{open.length === 0 && done.length === 0 && (
<div className="empty-state">No tasks yet — add one above.</div>
)}
{done.length > 0 && (
<>
<div className="section-title">Completed ({done.length})</div>
{done.map(t => (
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)} />
))}
</>
)}
{editing && list && <ListEditor list={list} onClose={() => setEditing(false)} />}
</div>
)
}

View file

@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
import { Bell, BellOff } from 'lucide-react'
import type { AppConfig } from '../types'
import { fetchConfig, updateConfig } from '../api'
import { useAuth } from '../components/AuthGate'
import { can } from '../types'
export default function Settings() {
const { user } = useAuth()
const editable = can(user, 'settings')
const [config, setConfig] = useState<AppConfig | null>(null)
const [error, setError] = useState<string | null>(null)
const [perm, setPerm] = useState<NotificationPermission>(
typeof Notification !== 'undefined' ? Notification.permission : 'denied'
)
useEffect(() => { fetchConfig().then(setConfig).catch(e => setError(e.message)) }, [])
async function set(key: keyof AppConfig, value: boolean) {
if (!config) return
setConfig({ ...config, [key]: value })
try { await updateConfig(key, value) }
catch (e) { setError(e instanceof Error ? e.message : 'Save failed') }
}
async function enablePush() {
if (typeof Notification === 'undefined') return
const p = await Notification.requestPermission()
setPerm(p)
}
if (!config) return <div className="page">{error ? <div className="error-banner">{error}</div> : 'Loading…'}</div>
return (
<div className="page" style={{ maxWidth: 620 }}>
<div className="page-header"><h1>Settings</h1></div>
{error && <div className="error-banner">{error}</div>}
<div className="section-title">Reminders</div>
<div className="card">
<label className="field-check" style={{ marginBottom: 12 }}>
<input type="checkbox" checked={config.remind_by_push} disabled={!editable}
onChange={e => set('remind_by_push', e.target.checked)} />
Send a push notification when a task reminder is due
</label>
<label className="field-check">
<input type="checkbox" checked={config.remind_by_email} disabled={!editable}
onChange={e => set('remind_by_email', e.target.checked)} />
Also email me when a reminder is due
</label>
<div className="field-hint" style={{ marginTop: 8 }}>
Reminders are checked every few minutes, so they fire close to the time you set — not to the exact second.
Email uses the stack SMTP settings (Settings app → integrations).
{!editable && ' Only administrators can change these.'}
</div>
</div>
<div className="section-title">This device</div>
<div className="card">
{perm === 'granted' ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--st-done)' }}>
<Bell size={16} strokeWidth={1.75} /> Push notifications are enabled on this device.
</div>
) : perm === 'denied' ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-mid)' }}>
<BellOff size={16} strokeWidth={1.75} /> Notifications are blocked in your browser settings for this site.
</div>
) : (
<div>
<div style={{ marginBottom: 10, color: 'var(--text-mid)' }}>
Allow notifications so reminders can reach you on this device.
</div>
<button className="btn btn-primary" onClick={enablePush}>
<Bell size={14} strokeWidth={1.75} /> Enable notifications
</button>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,62 @@
import { useEffect, useState, useCallback } from 'react'
import { CalendarDays } from 'lucide-react'
import type { Task } from '../types'
import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
import TaskRow from '../components/TaskRow'
function startOfDay(d: Date) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
export default function TodayView() {
const { openTask, changeVersion } = useTaskDrawer()
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
fetchTasks({ view: 'today' }).then(setTasks).catch(e => setError(e.message))
}, [])
useEffect(() => { load() }, [load, changeVersion])
async function toggle(t: Task) {
await updateTask(t.id, { status: t.status === 'done' ? 'todo' : 'done' })
load()
}
const now = new Date()
const todayStart = startOfDay(now)
const tomorrowStart = new Date(todayStart); tomorrowStart.setDate(tomorrowStart.getDate() + 1)
const overdue = tasks.filter(t => t.due_at && new Date(t.due_at) < todayStart)
const today = tasks.filter(t => t.due_at && new Date(t.due_at) >= todayStart && new Date(t.due_at) < tomorrowStart)
const upcoming = tasks.filter(t => t.due_at && new Date(t.due_at) >= tomorrowStart)
const groups: Array<{ key: string; title: string; items: Task[]; overdue?: boolean }> = [
{ key: 'overdue', title: 'Overdue', items: overdue, overdue: true },
{ key: 'today', title: 'Today', items: today },
{ key: 'upcoming', title: 'Next 7 days', items: upcoming },
]
return (
<div className="page">
<div className="page-header">
<h1><CalendarDays size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Today</h1>
</div>
{error && <div className="error-banner">{error}</div>}
{tasks.length === 0 && (
<div className="empty-state">Nothing due in the next 7 days. Enjoy the calm. ☕</div>
)}
{groups.map(g => g.items.length > 0 && (
<div key={g.key}>
<div className={`agenda-group-title${g.overdue ? ' overdue' : ''}`}>
{g.title} <span className="muted" style={{ fontWeight: 400 }}>· {g.items.length}</span>
</div>
{g.items.map(t => (
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
))}
</div>
))}
</div>
)
}

35
frontend/src/sw.js Normal file
View file

@ -0,0 +1,35 @@
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
precacheAndRoute(self.__WB_MANIFEST)
// SPA fallback: navigate requests that don't match a cached asset serve index.html
registerRoute(
new NavigationRoute(createHandlerBoundToURL('/tasks/index.html'), {
denylist: [/\/api\//],
})
)
self.addEventListener('push', event => {
const data = event.data?.json() ?? {}
event.waitUntil(
self.registration.showNotification(data.title || 'Tasks', {
body: data.body || '',
icon: '/tasks/icons/icon-192.png',
badge: '/tasks/icons/icon-192.png',
data: { url: data.url || '/tasks/' },
tag: data.tag || 'tasks',
})
)
})
self.addEventListener('notificationclick', event => {
event.notification.close()
const url = event.notification.data?.url || '/tasks/'
event.waitUntil(
clients.matchAll({ type: 'window' }).then(list => {
const existing = list.find(c => c.url.includes('/tasks/') && 'focus' in c)
return existing ? existing.focus() : clients.openWindow(url)
})
)
})

97
frontend/src/types.ts Normal file
View file

@ -0,0 +1,97 @@
export type Priority = 'low' | 'medium' | 'high'
export type Status = 'todo' | 'doing' | 'done'
export type RecurRule = 'daily' | 'weekdays' | 'weekly' | 'monthly'
export const PRIORITIES: Priority[] = ['low', 'medium', 'high']
export const PRIORITY_LABELS: Record<Priority, string> = { low: 'Low', medium: 'Medium', high: 'High' }
export const STATUSES: Status[] = ['todo', 'doing', 'done']
export const STATUS_LABELS: Record<Status, string> = { todo: 'To do', doing: 'Doing', done: 'Done' }
export const RECUR_RULES: RecurRule[] = ['daily', 'weekdays', 'weekly', 'monthly']
export const RECUR_LABELS: Record<RecurRule, string> = {
daily: 'Every day',
weekdays: 'Every weekday',
weekly: 'Every week',
monthly: 'Every month',
}
// Preset list colours (user data, not design-system chrome).
export const LIST_COLOURS = ['#c9a84c', '#0d9488', '#2563eb', '#7c3aed', '#dc2626', '#d97706', '#16a34a', '#64748b']
export interface List {
id: number
owner_id: string
name: string
colour: string
icon: string
sort_order: number
is_archived: boolean
open_count: number
total_count: number
created_at: string
}
export interface Task {
id: number
list_id: number
owner_id: string
created_by: string
assigned_to: string | null
title: string
notes: string | null
status: Status
priority: Priority
due_at: string | null
remind_at: string | null
reminded_at: string | null
recur_rule: RecurRule | null
completed_at: string | null
sort_order: number
created_at: string
updated_at: string
// joined / derived
list_name?: string
list_colour?: string
item_count?: number
item_done?: number
}
export interface TaskItem {
id: number
task_id: number
label: string
is_done: boolean
sort_order: number
}
export interface TaskComment {
id: number
task_id: number
author_email: string | null
author_name: string | null
body: string
created_at: string
}
export interface TaskDetail extends Task {
items: TaskItem[]
comments: TaskComment[]
}
export interface AppConfig {
remind_by_push: boolean
remind_by_email: boolean
}
export interface User {
user_id: number
name: string
email: string
is_admin: boolean
caps: string[] // bare slugs — verify?app=tasks strips the prefix
}
export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap)
}

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

19
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}

32
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,32 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
base: '/tasks/',
plugins: [
react(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.js',
registerType: 'autoUpdate',
manifest: {
name: 'Tasks',
short_name: 'Tasks',
start_url: '/tasks/',
scope: '/tasks/',
display: 'standalone',
theme_color: '#0d9488',
background_color: '#0d9488',
icons: [
{ src: '/tasks/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/tasks/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
injectManifest: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
},
}),
],
})

58
seed-app.js Normal file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env node
// Run from tasks/ dir: DATABASE_URL=... node seed-app.js
import pg from 'pg'
const { Pool } = pg
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
await pool.query(`
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
VALUES ('tasks', 'Tasks', 'Personal task planner — lists, reminders and recurring to-dos', '/tasks', 'ListChecks', '#0d9488', 'Operations', '10.10.10.130', 3080)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
base_path = EXCLUDED.base_path,
icon = EXCLUDED.icon,
theme_color = EXCLUDED.theme_color,
category = EXCLUDED.category,
internal_host = EXCLUDED.internal_host,
internal_port = EXCLUDED.internal_port
`)
// Seed capabilities. 'admin' stays reserved for a future "view/manage every
// field of every user's tasks" surface — not built yet. 'reallocate' is the
// narrower dispatcher power: see everyone's boards and move tasks between
// people, without the full weight of editing/deleting someone else's task.
await pool.query(`
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
SELECT a.id, c.slug, c.name, c.description, c.sort_order
FROM apps a, (VALUES
('use', 'Use Tasks', 'Create and manage your own lists and tasks', 1),
('assign', 'Assign Tasks', 'Hand off your own tasks to other users', 2),
('reallocate', 'Reallocate Tasks', 'View every user''s board and move any task between people', 3),
('admin', 'Manage All', 'View and manage every field of every user''s tasks (not yet built)', 4),
('settings', 'Settings', 'Configure the shared status pipeline and categories', 5)
) AS c(slug, name, description, sort_order)
WHERE a.slug = 'tasks'
ON CONFLICT (app_id, slug) DO NOTHING
`)
// Grant 'use' to the Staff role if they have no tasks caps yet.
await pool.query(`
INSERT INTO role_capabilities (role_id, capability_id)
SELECT r.id, ac.id
FROM roles r
JOIN app_capabilities ac ON ac.slug = 'use'
JOIN apps a ON a.id = ac.app_id AND a.slug = 'tasks'
WHERE r.name = 'Staff'
AND NOT EXISTS (
SELECT 1 FROM role_capabilities rc2
JOIN app_capabilities ac2 ON rc2.capability_id = ac2.id
JOIN apps a2 ON ac2.app_id = a2.id AND a2.slug = 'tasks'
WHERE rc2.role_id = r.id
)
ON CONFLICT DO NOTHING
`)
console.log('tasks app seeded.')
await pool.end()