Add recurring events, push notifications, config sync — and mobile layout fixes
Recurring event editing (rrule) with this/all occurrence scope, web push subscriptions (VAPID) with a cached config layer for notification settings, and email delivery via nodemailer. Also fixes the calendar view never actually stacking on mobile: the Calendars filter column used flex:1 with minWidth:0 on its sibling, so flex-wrap never triggered regardless of viewport width, squeezing the grid and view switcher into a sliver next to a fixed 220px sidebar. Adds a proper mobile breakpoint that stacks the layout, scrolls the week grid horizontally instead of compressing it, and enlarges touch targets.
This commit is contained in:
parent
dad3cc472c
commit
ca0dc9b070
28 changed files with 1517 additions and 155 deletions
217
backend/package-lock.json
generated
217
backend/package-lock.json
generated
|
|
@ -18,7 +18,10 @@
|
||||||
"ical.js": "^2.1.0",
|
"ical.js": "^2.1.0",
|
||||||
"ics": "^3.8.1",
|
"ics": "^3.8.1",
|
||||||
"jose": "^5.9.6",
|
"jose": "^5.9.6",
|
||||||
"pg": "^8.13.1"
|
"nodemailer": "^9.0.3",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"rrule": "^2.8.1",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/accept-negotiator": {
|
"node_modules/@fastify/accept-negotiator": {
|
||||||
|
|
@ -215,6 +218,15 @@
|
||||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
|
|
@ -288,6 +300,18 @@
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/atomic-sleep": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
|
|
@ -325,6 +349,12 @@
|
||||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||||
|
|
@ -334,6 +364,12 @@
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
|
@ -346,26 +382,6 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/content-disposition/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/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
|
@ -398,6 +414,23 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
|
@ -413,6 +446,15 @@
|
||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
|
@ -605,6 +647,15 @@
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
|
|
@ -621,6 +672,19 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/ical.js": {
|
"node_modules/ical.js": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz",
|
||||||
|
|
@ -707,6 +771,27 @@
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/light-my-request": {
|
||||||
"version": "5.14.0",
|
"version": "5.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
|
||||||
|
|
@ -736,6 +821,12 @@
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.9",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
|
|
@ -751,6 +842,15 @@
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/minipass": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
|
|
@ -769,6 +869,12 @@
|
||||||
"obliterator": "^2.0.1"
|
"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/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||||
|
|
@ -787,6 +893,15 @@
|
||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/obliterator": {
|
"node_modules/obliterator": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||||
|
|
@ -1088,12 +1203,41 @@
|
||||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/rrule": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/runes2": {
|
"node_modules/runes2": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz",
|
||||||
"integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==",
|
"integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/safe-regex2": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
|
||||||
|
|
@ -1112,6 +1256,12 @@
|
||||||
"node": ">=10"
|
"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": {
|
"node_modules/secure-json-parse": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
|
||||||
|
|
@ -1346,6 +1496,12 @@
|
||||||
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
|
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/type-fest": {
|
"node_modules/type-fest": {
|
||||||
"version": "2.19.0",
|
"version": "2.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||||
|
|
@ -1358,6 +1514,25 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@
|
||||||
"ical.js": "^2.1.0",
|
"ical.js": "^2.1.0",
|
||||||
"ics": "^3.8.1",
|
"ics": "^3.8.1",
|
||||||
"jose": "^5.9.6",
|
"jose": "^5.9.6",
|
||||||
"pg": "^8.13.1"
|
"nodemailer": "^9.0.3",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"rrule": "^2.8.1",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ export async function initDb() {
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
calendar_id INT NOT NULL REFERENCES calendars(id),
|
calendar_id INT NOT NULL REFERENCES calendars(id),
|
||||||
|
|
@ -26,6 +32,7 @@ export async function initDb() {
|
||||||
start_at TIMESTAMPTZ NOT NULL,
|
start_at TIMESTAMPTZ NOT NULL,
|
||||||
end_at TIMESTAMPTZ NOT NULL,
|
end_at TIMESTAMPTZ NOT NULL,
|
||||||
all_day BOOLEAN NOT NULL DEFAULT FALSE,
|
all_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
rrule TEXT, -- RFC 5545 RRULE value (no "RRULE:" prefix); non-null = recurring master
|
||||||
sequence INT NOT NULL DEFAULT 0,
|
sequence INT NOT NULL DEFAULT 0,
|
||||||
created_by TEXT, -- email
|
created_by TEXT, -- email
|
||||||
created_by_name TEXT,
|
created_by_name TEXT,
|
||||||
|
|
@ -37,6 +44,47 @@ export async function initDb() {
|
||||||
CREATE INDEX IF NOT EXISTS events_start_idx ON events (start_at);
|
CREATE INDEX IF NOT EXISTS events_start_idx ON events (start_at);
|
||||||
CREATE INDEX IF NOT EXISTS events_deleted_idx ON events (deleted_at);
|
CREATE INDEX IF NOT EXISTS events_deleted_idx ON events (deleted_at);
|
||||||
|
|
||||||
|
-- Per-occurrence overrides/cancellations for a recurring master event.
|
||||||
|
-- occurrence_start is the ORIGINAL (un-shifted) RRULE-computed occurrence
|
||||||
|
-- time — RECURRENCE-ID semantics. NULL on an override field = inherit
|
||||||
|
-- from the master. Deliberately does not cover departments/assignees/
|
||||||
|
-- attachments — every occurrence of a series shows the master's tags.
|
||||||
|
CREATE TABLE IF NOT EXISTS event_exceptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
master_event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
occurrence_start TIMESTAMPTZ NOT NULL,
|
||||||
|
is_cancelled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
location TEXT,
|
||||||
|
start_at TIMESTAMPTZ,
|
||||||
|
end_at TIMESTAMPTZ,
|
||||||
|
all_day BOOLEAN,
|
||||||
|
updated_by TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (master_event_id, occurrence_start)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Dedupes reminder sends across the 5-minute scheduler poll interval.
|
||||||
|
CREATE TABLE IF NOT EXISTS event_reminders_sent (
|
||||||
|
event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
occurrence_start TIMESTAMPTZ NOT NULL,
|
||||||
|
user_email TEXT NOT NULL,
|
||||||
|
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (event_id, occurrence_start, user_email)
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS event_departments (
|
CREATE TABLE IF NOT EXISTS event_departments (
|
||||||
event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
department_id TEXT NOT NULL,
|
department_id TEXT NOT NULL,
|
||||||
|
|
@ -89,6 +137,9 @@ export async function initDb() {
|
||||||
CREATE INDEX IF NOT EXISTS activity_log_entity_idx ON activity_log (entity_type, entity_id);
|
CREATE INDEX IF NOT EXISTS activity_log_entity_idx ON activity_log (entity_type, entity_id);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// Incremental migration — idempotent, for deploys that predate recurrence support.
|
||||||
|
await pool.query(`ALTER TABLE events ADD COLUMN IF NOT EXISTS rrule TEXT`)
|
||||||
|
|
||||||
await seedDefaults()
|
await seedDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,4 +154,21 @@ async function seedDefaults() {
|
||||||
VALUES ('general', 'All Staff', '#c9a84c', FALSE)
|
VALUES ('general', 'All Staff', '#c9a84c', FALSE)
|
||||||
ON CONFLICT (slug) DO NOTHING`
|
ON CONFLICT (slug) DO NOTHING`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
notify_on_assignment: true,
|
||||||
|
notify_reminders: true,
|
||||||
|
reminder_minutes_before: 60,
|
||||||
|
}
|
||||||
|
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)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfig() {
|
||||||
|
const { rows } = await pool.query('SELECT key, value FROM config')
|
||||||
|
return Object.fromEntries(rows.map(r => [r.key, r.value]))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
backend/src/lib/config-cache.js
Normal file
17
backend/src/lib/config-cache.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Short-TTL cache around db.js's getConfig(), so the assignment-notification
|
||||||
|
// check on every event write (routes/events.js) and the 5-minute reminder
|
||||||
|
// poll (lib/reminders.js) don't each round-trip the config table on every
|
||||||
|
// call. Mirrors maintenance/backend/src/lib/mailer.js's SMTP-config cache
|
||||||
|
// pattern, applied to the whole config table instead of just SMTP settings.
|
||||||
|
import { getConfig } from '../db.js'
|
||||||
|
|
||||||
|
const TTL_MS = 90 * 1000
|
||||||
|
|
||||||
|
let _cache = null // { value, expiresAt }
|
||||||
|
|
||||||
|
export async function getCachedConfig() {
|
||||||
|
if (_cache && Date.now() < _cache.expiresAt) return _cache.value
|
||||||
|
const value = await getConfig()
|
||||||
|
_cache = { value, expiresAt: Date.now() + TTL_MS }
|
||||||
|
return value
|
||||||
|
}
|
||||||
89
backend/src/lib/mailer.js
Normal file
89
backend/src/lib/mailer.js
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
// Settings-fetched SMTP mailer — copied from maintenance/backend/src/lib/mailer.js's
|
||||||
|
// pattern (5-minute cached SMTP config from the central settings app, lazily
|
||||||
|
// built nodemailer transport, fire-and-forget send()).
|
||||||
|
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 an event write.
|
||||||
|
async function send(to, subject, text) {
|
||||||
|
if (!to) return
|
||||||
|
try {
|
||||||
|
const config = await getSmtpConfig()
|
||||||
|
const transport = await getTransporter()
|
||||||
|
await transport.sendMail({
|
||||||
|
from: config.from || `"${HOTEL_NAME} Calendar" <noreply@localhost>`,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Calendar mail to ${to} failed: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(d, allDay) {
|
||||||
|
const dt = new Date(d)
|
||||||
|
return allDay
|
||||||
|
? dt.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
|
||||||
|
: dt.toLocaleString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventSummary(event) {
|
||||||
|
const lines = [
|
||||||
|
`Event: ${event.title}`,
|
||||||
|
`When: ${fmtDateTime(event.start_at, event.all_day)} – ${fmtDateTime(event.end_at, event.all_day)}`,
|
||||||
|
]
|
||||||
|
if (event.location) lines.push(`Where: ${event.location}`)
|
||||||
|
if (event.description) lines.push('', event.description)
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyAssignment(event, toEmail, toName) {
|
||||||
|
return send(
|
||||||
|
toEmail,
|
||||||
|
`[Calendar] Added to: ${event.title}`,
|
||||||
|
`You've been added to an event.\n\n${eventSummary(event)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyReminder(event, toEmail) {
|
||||||
|
return send(
|
||||||
|
toEmail,
|
||||||
|
`[Calendar] Starting soon: ${event.title}`,
|
||||||
|
`An event you're assigned to is starting soon.\n\n${eventSummary(event)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
81
backend/src/lib/push.js
Normal file
81
backend/src/lib/push.js
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// Web push — copied from maintenance/backend/src/lib/push.js's pattern:
|
||||||
|
// VAPID keys auto-generated into the config table on first boot (or taken
|
||||||
|
// from env vars if set), subscriptions in push_subscriptions, expired/invalid
|
||||||
|
// subscriptions cleaned up on 404/410 from the push service.
|
||||||
|
import webPush from 'web-push'
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
|
||||||
|
let _publicKey = null
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyAssignmentPush(event, toEmail) {
|
||||||
|
if (!toEmail) return Promise.resolve()
|
||||||
|
return sendPushToUser(toEmail, {
|
||||||
|
title: `Added to: ${event.title}`,
|
||||||
|
body: event.location || 'Calendar event',
|
||||||
|
url: '/calendar/',
|
||||||
|
tag: `calendar-event-${event.id}`,
|
||||||
|
}).catch(err => console.error('[push] notifyAssignmentPush failed:', err.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyReminderPush(event, toEmail) {
|
||||||
|
if (!toEmail) return Promise.resolve()
|
||||||
|
return sendPushToUser(toEmail, {
|
||||||
|
title: `Starting soon: ${event.title}`,
|
||||||
|
body: event.location || 'Calendar event',
|
||||||
|
url: '/calendar/',
|
||||||
|
tag: `calendar-event-${event.id}`,
|
||||||
|
}).catch(err => console.error('[push] notifyReminderPush failed:', err.message))
|
||||||
|
}
|
||||||
122
backend/src/lib/recurrence.js
Normal file
122
backend/src/lib/recurrence.js
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
// Recurrence math for RRULE-based recurring events. All date arithmetic goes
|
||||||
|
// through the `rrule` npm package — nothing here hand-rolls RRULE expansion.
|
||||||
|
//
|
||||||
|
// Timezone note: like the rest of this app (see lib/bank-holidays.js,
|
||||||
|
// lib/caldav-store.js), timestamps are stored/compared as plain UTC instants
|
||||||
|
// and treated as the hotel's "local" wall-clock time — there's no separate
|
||||||
|
// per-event timezone concept. `rrule` treats dates the same way (its docs
|
||||||
|
// call this "floating"/UTC time — see the README's "Use UTC dates" section):
|
||||||
|
// as long as every Date we hand it and read back from it is real UTC
|
||||||
|
// (which is what `pg` already gives us for TIMESTAMPTZ columns), expansion
|
||||||
|
// is internally consistent with how the rest of the app reads/writes time.
|
||||||
|
import { RRule } from 'rrule'
|
||||||
|
|
||||||
|
// event_exceptions rows only override title/description/location/timing/
|
||||||
|
// cancellation — departments/assignees/attachments always come from the
|
||||||
|
// master (see db.js's event_exceptions comment / the recurring-events scope
|
||||||
|
// note in the spec this was built against).
|
||||||
|
const OVERRIDE_FIELDS = ['title', 'description', 'location', 'start_at', 'end_at', 'all_day']
|
||||||
|
|
||||||
|
function buildRRule(master) {
|
||||||
|
// RRule.fromString() alone has no way to inject a dtstart that isn't
|
||||||
|
// embedded in the string itself, so parse the bare RRULE value (our
|
||||||
|
// `rrule` column never stores a DTSTART line — see db.js) and set dtstart
|
||||||
|
// explicitly from the master's own start_at, per spec.
|
||||||
|
const opts = RRule.parseString(master.rrule)
|
||||||
|
opts.dtstart = new Date(master.start_at)
|
||||||
|
return new RRule(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
function occurrenceKey(d) {
|
||||||
|
return new Date(d).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexExceptions(exceptions) {
|
||||||
|
const map = new Map()
|
||||||
|
for (const ex of exceptions || []) map.set(occurrenceKey(ex.occurrence_start), ex)
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merges an exception's non-null override fields over the master's fields
|
||||||
|
// for one occurrence. `occDate` is the original (un-shifted) RRULE-computed
|
||||||
|
// occurrence date.
|
||||||
|
function effectiveFields(master, ex, occDate, durationMs) {
|
||||||
|
const base = {
|
||||||
|
title: master.title,
|
||||||
|
description: master.description,
|
||||||
|
location: master.location,
|
||||||
|
start_at: occDate,
|
||||||
|
end_at: new Date(occDate.getTime() + durationMs),
|
||||||
|
all_day: master.all_day,
|
||||||
|
}
|
||||||
|
if (!ex) return base
|
||||||
|
for (const f of OVERRIDE_FIELDS) {
|
||||||
|
if (ex[f] === null || ex[f] === undefined) continue
|
||||||
|
base[f] = f === 'start_at' || f === 'end_at' ? new Date(ex[f]) : ex[f]
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expands a recurring master into virtual occurrence objects overlapping
|
||||||
|
// [rangeStart, rangeEnd], shaped like queryEventSummaries's rows.
|
||||||
|
// `master` needs: id, calendar_id, calendar_name, calendar_color, uid,
|
||||||
|
// title, description, location, start_at, end_at, all_day, rrule,
|
||||||
|
// department_names, assignee_names, attachment_count.
|
||||||
|
export function expandOccurrences(master, exceptions, rangeStart, rangeEnd) {
|
||||||
|
if (!master?.rrule) return []
|
||||||
|
const rule = buildRRule(master)
|
||||||
|
const durationMs = new Date(master.end_at).getTime() - new Date(master.start_at).getTime()
|
||||||
|
const exByKey = indexExceptions(exceptions)
|
||||||
|
|
||||||
|
const occDates = rule.between(new Date(rangeStart), new Date(rangeEnd), true)
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
for (const occDate of occDates) {
|
||||||
|
const key = occurrenceKey(occDate)
|
||||||
|
const ex = exByKey.get(key)
|
||||||
|
if (ex?.is_cancelled) continue
|
||||||
|
|
||||||
|
const fields = effectiveFields(master, ex, occDate, durationMs)
|
||||||
|
results.push({
|
||||||
|
id: master.id,
|
||||||
|
calendar_id: master.calendar_id,
|
||||||
|
calendar_name: master.calendar_name,
|
||||||
|
calendar_color: master.calendar_color,
|
||||||
|
uid: master.uid,
|
||||||
|
title: fields.title,
|
||||||
|
location: fields.location,
|
||||||
|
start_at: fields.start_at,
|
||||||
|
end_at: fields.end_at,
|
||||||
|
all_day: fields.all_day,
|
||||||
|
department_names: master.department_names,
|
||||||
|
assignee_names: master.assignee_names,
|
||||||
|
attachment_count: master.attachment_count,
|
||||||
|
is_recurring: true,
|
||||||
|
occurrence_start: key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves a single occurrence's effective fields (for the occurrence-scoped
|
||||||
|
// detail GET). Returns null if the occurrence is cancelled, or isn't
|
||||||
|
// actually a real RRULE occurrence (validated against the RRULE itself,
|
||||||
|
// not trusted blindly from the caller).
|
||||||
|
export function getEffectiveOccurrence(master, exceptions, occurrenceStart) {
|
||||||
|
if (!master?.rrule) return null
|
||||||
|
const target = new Date(occurrenceStart)
|
||||||
|
if (isNaN(target.getTime())) return null
|
||||||
|
|
||||||
|
const rule = buildRRule(master)
|
||||||
|
const matches = rule.between(target, target, true)
|
||||||
|
if (!matches.length) return null
|
||||||
|
|
||||||
|
const exByKey = indexExceptions(exceptions)
|
||||||
|
const key = occurrenceKey(target)
|
||||||
|
const ex = exByKey.get(key)
|
||||||
|
if (ex?.is_cancelled) return null
|
||||||
|
|
||||||
|
const durationMs = new Date(master.end_at).getTime() - new Date(master.start_at).getTime()
|
||||||
|
const fields = effectiveFields(master, ex, target, durationMs)
|
||||||
|
return { ...fields, is_exception: !!ex }
|
||||||
|
}
|
||||||
28
backend/src/routes/config.js
Normal file
28
backend/src/routes/config.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
|
import { pool } from '../db.js'
|
||||||
|
|
||||||
|
export async function configRoutes(app) {
|
||||||
|
app.addHook('preHandler', requireAuth)
|
||||||
|
|
||||||
|
// GET /api/config — all config keys as a flat object
|
||||||
|
app.get('/api/config', { preHandler: requireCap('view') }, async () => {
|
||||||
|
const { rows } = await pool.query('SELECT key, value FROM config ORDER BY key')
|
||||||
|
return Object.fromEntries(rows.map(r => [r.key, r.value]))
|
||||||
|
})
|
||||||
|
|
||||||
|
// PATCH /api/config/:key — update a single config key. Gated on 'admin'
|
||||||
|
// (same cap that already gates the activity log) rather than a new
|
||||||
|
// capability — nothing about notification settings warrants a separate one.
|
||||||
|
app.patch('/api/config/:key', { preHandler: requireCap('admin') }, async (req, reply) => {
|
||||||
|
const { key } = req.params
|
||||||
|
const { value } = req.body || {}
|
||||||
|
if (value === undefined) return reply.status(400).send({ error: 'value required' })
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
|
||||||
|
[key, JSON.stringify(value)]
|
||||||
|
)
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
36
backend/src/routes/push.js
Normal file
36
backend/src/routes/push.js
Normal 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
|
|
@ -11,7 +11,8 @@
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0"
|
"react-router-dom": "^6.28.0",
|
||||||
|
"rrule": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.1",
|
||||||
|
|
@ -5047,6 +5048,15 @@
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rrule": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-array-concat": {
|
"node_modules/safe-array-concat": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
|
||||||
|
|
@ -5548,6 +5558,12 @@
|
||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/type-fest": {
|
"node_modules/type-fest": {
|
||||||
"version": "0.16.0",
|
"version": "0.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0"
|
"react-router-dom": "^6.28.0",
|
||||||
|
"rrule": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
Calendar, EventSummary, EventDetail, EventAttachment,
|
Calendar, EventSummary, EventDetail, EventAttachment,
|
||||||
ActivityLogEntry, Department, AuthUser, CaldavCredential, CaldavCredentialCreated,
|
ActivityLogEntry, Department, AuthUser, CaldavCredential, CaldavCredentialCreated,
|
||||||
|
AppConfig,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const BASE = '/calendar/api'
|
const BASE = '/calendar/api'
|
||||||
|
|
@ -53,8 +54,11 @@ export function fetchEvents(filters: EventFilters = {}): Promise<EventSummary[]>
|
||||||
const qs = params.toString()
|
const qs = params.toString()
|
||||||
return request(`/events${qs ? `?${qs}` : ''}`)
|
return request(`/events${qs ? `?${qs}` : ''}`)
|
||||||
}
|
}
|
||||||
export function fetchEvent(id: number): Promise<EventDetail> {
|
// occurrence_start: pass when fetching the effective (merged master+override)
|
||||||
return request(`/events/${id}`)
|
// fields for one occurrence of a recurring series rather than the master row.
|
||||||
|
export function fetchEvent(id: number, occurrence_start?: string): Promise<EventDetail> {
|
||||||
|
const qs = occurrence_start ? `?occurrence_start=${encodeURIComponent(occurrence_start)}` : ''
|
||||||
|
return request(`/events/${id}${qs}`)
|
||||||
}
|
}
|
||||||
export interface EventBody {
|
export interface EventBody {
|
||||||
calendar_id: number
|
calendar_id: number
|
||||||
|
|
@ -66,15 +70,32 @@ export interface EventBody {
|
||||||
all_day: boolean
|
all_day: boolean
|
||||||
departments?: { id: string; name: string }[]
|
departments?: { id: string; name: string }[]
|
||||||
assignees?: { email: string; name: string }[]
|
assignees?: { email: string; name: string }[]
|
||||||
|
// RFC 5545 RRULE property value, e.g. "FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10" —
|
||||||
|
// bare value, no "RRULE:" prefix and no DTSTART line.
|
||||||
|
rrule?: string | null
|
||||||
|
}
|
||||||
|
export interface EventScopeOptions {
|
||||||
|
// Present only when scope is 'this' — identifies which occurrence.
|
||||||
|
occurrence_start?: string
|
||||||
|
// 'this' edits/deletes just that occurrence (creates/updates a per-occurrence
|
||||||
|
// override); 'all' (default) edits/deletes the whole series (the master row).
|
||||||
|
scope?: 'this' | 'all'
|
||||||
}
|
}
|
||||||
export function createEvent(body: EventBody): Promise<EventDetail> {
|
export function createEvent(body: EventBody): Promise<EventDetail> {
|
||||||
return request('/events', { method: 'POST', body: JSON.stringify(body) })
|
return request('/events', { method: 'POST', body: JSON.stringify(body) })
|
||||||
}
|
}
|
||||||
export function updateEvent(id: number, body: Partial<EventBody>): Promise<EventDetail> {
|
export function updateEvent(id: number, body: Partial<EventBody>, opts: EventScopeOptions = {}): Promise<EventDetail> {
|
||||||
return request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
const payload: Partial<EventBody> & EventScopeOptions = { ...body }
|
||||||
|
if (opts.occurrence_start) payload.occurrence_start = opts.occurrence_start
|
||||||
|
if (opts.scope) payload.scope = opts.scope
|
||||||
|
return request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
|
||||||
}
|
}
|
||||||
export function deleteEvent(id: number): Promise<{ ok: boolean }> {
|
export function deleteEvent(id: number, opts: EventScopeOptions = {}): Promise<{ ok: boolean }> {
|
||||||
return request(`/events/${id}`, { method: 'DELETE' })
|
const params = new URLSearchParams()
|
||||||
|
if (opts.occurrence_start) params.set('occurrence_start', opts.occurrence_start)
|
||||||
|
if (opts.scope) params.set('scope', opts.scope)
|
||||||
|
const qs = params.toString()
|
||||||
|
return request(`/events/${id}${qs ? `?${qs}` : ''}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attachments — multipart, so no JSON content-type header
|
// Attachments — multipart, so no JSON content-type header
|
||||||
|
|
@ -141,3 +162,22 @@ export async function fetchAssignableUsers(): Promise<AuthUser[]> {
|
||||||
if (!res.ok) throw new Error(`Failed to load users: ${res.status}`)
|
if (!res.ok) throw new Error(`Failed to load users: ${res.status}`)
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App config — notification settings, admin-editable
|
||||||
|
export function fetchConfig(): Promise<AppConfig> {
|
||||||
|
return request('/config')
|
||||||
|
}
|
||||||
|
export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> {
|
||||||
|
return request(`/config/${key}`, { method: 'PATCH', body: JSON.stringify({ value }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push notifications
|
||||||
|
export function fetchVapidKey(): Promise<{ publicKey: string }> {
|
||||||
|
return request('/push/vapid-key')
|
||||||
|
}
|
||||||
|
export function subscribePush(subscription: PushSubscriptionJSON): Promise<{ ok: boolean }> {
|
||||||
|
return request('/push/subscribe', { method: 'POST', body: JSON.stringify(subscription) })
|
||||||
|
}
|
||||||
|
export function unsubscribePush(endpoint?: string): Promise<{ ok: boolean }> {
|
||||||
|
return request('/push/unsubscribe', { method: 'DELETE', body: JSON.stringify(endpoint ? { endpoint } : {}) })
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState, createContext, useContext } from 'react'
|
import { useEffect, useState, createContext, useContext } from 'react'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
import { usePushSubscription } from '../hooks/usePushSubscription'
|
||||||
|
|
||||||
interface AuthCtx { user: User }
|
interface AuthCtx { user: User }
|
||||||
const Ctx = createContext<AuthCtx | null>(null)
|
const Ctx = createContext<AuthCtx | null>(null)
|
||||||
|
|
@ -10,6 +11,11 @@ export function useAuth() {
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PushSubscriber({ user }: { user: User }) {
|
||||||
|
usePushSubscription(user)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
|
||||||
|
|
@ -39,6 +45,7 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Ctx.Provider value={{ user }}>
|
<Ctx.Provider value={{ user }}>
|
||||||
|
<PushSubscriber user={user} />
|
||||||
{children}
|
{children}
|
||||||
</Ctx.Provider>
|
</Ctx.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { X, Trash2, ChevronDown, ChevronRight, Lock } from 'lucide-react'
|
import { X, Trash2, ChevronDown, ChevronRight, Lock, Repeat } from 'lucide-react'
|
||||||
import type { Calendar, Department, EventAttachment, AuthUser, ActivityLogEntry } from '../types'
|
import type { Calendar, Department, EventAttachment, AuthUser, ActivityLogEntry } from '../types'
|
||||||
import { can } from '../types'
|
import { can } from '../types'
|
||||||
import { useAuth } from './AuthGate'
|
import { useAuth } from './AuthGate'
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import type { EventBody } from '../api'
|
import type { EventBody } from '../api'
|
||||||
import AttachmentList from './AttachmentList'
|
import AttachmentList from './AttachmentList'
|
||||||
|
import RecurrenceEditor from './RecurrenceEditor'
|
||||||
import { toISODate, toTimeInput } from '../dateUtils'
|
import { toISODate, toTimeInput } from '../dateUtils'
|
||||||
|
|
||||||
interface Assignee { email: string; name: string }
|
interface Assignee { email: string; name: string }
|
||||||
|
|
@ -16,14 +17,23 @@ interface Assignee { email: string; name: string }
|
||||||
interface EventFormProps {
|
interface EventFormProps {
|
||||||
eventId?: number
|
eventId?: number
|
||||||
initialDate?: Date
|
initialDate?: Date
|
||||||
|
// Set (by the caller, after an OccurrenceScopePrompt choice) when the
|
||||||
|
// clicked event is an occurrence of a recurring series.
|
||||||
|
occurrenceStart?: string
|
||||||
|
scope?: 'this' | 'all'
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventForm({ eventId, initialDate, onClose, onSaved }: EventFormProps) {
|
export default function EventForm({ eventId, initialDate, occurrenceStart, scope: scopeProp, onClose, onSaved }: EventFormProps) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const editing = eventId != null
|
const editing = eventId != null
|
||||||
const permitted = editing ? can(user, 'edit') : can(user, 'create')
|
const permitted = editing ? can(user, 'edit') : can(user, 'create')
|
||||||
|
const scope: 'this' | 'all' = scopeProp ?? 'all'
|
||||||
|
// Editing a single occurrence — departments/assignees/attachments/repeat
|
||||||
|
// rules are series-level only and can't be touched from this path (see
|
||||||
|
// "deliberate scope limit" in the REST contract).
|
||||||
|
const editingOccurrence = editing && scope === 'this'
|
||||||
|
|
||||||
const [loading, setLoading] = useState(editing)
|
const [loading, setLoading] = useState(editing)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -49,6 +59,8 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
||||||
const [selectedAssignees, setSelectedAssignees] = useState<Assignee[]>([])
|
const [selectedAssignees, setSelectedAssignees] = useState<Assignee[]>([])
|
||||||
const [attachments, setAttachments] = useState<EventAttachment[]>([])
|
const [attachments, setAttachments] = useState<EventAttachment[]>([])
|
||||||
const [isSystem, setIsSystem] = useState(false)
|
const [isSystem, setIsSystem] = useState(false)
|
||||||
|
const [rrule, setRrule] = useState<string | null>(null)
|
||||||
|
const [isRecurring, setIsRecurring] = useState(false)
|
||||||
|
|
||||||
const [historyOpen, setHistoryOpen] = useState(false)
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
const [history, setHistory] = useState<ActivityLogEntry[] | null>(null)
|
const [history, setHistory] = useState<ActivityLogEntry[] | null>(null)
|
||||||
|
|
@ -70,7 +82,9 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing || !eventId) return
|
if (!editing || !eventId) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
fetchEvent(eventId)
|
// For a 'this'-scoped edit, fetch the merged (master + override) fields
|
||||||
|
// for that one occurrence rather than the master row's own fields.
|
||||||
|
fetchEvent(eventId, editingOccurrence ? occurrenceStart : undefined)
|
||||||
.then(ev => {
|
.then(ev => {
|
||||||
setCalendarId(ev.calendar_id)
|
setCalendarId(ev.calendar_id)
|
||||||
setTitle(ev.title)
|
setTitle(ev.title)
|
||||||
|
|
@ -87,9 +101,15 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
||||||
setSelectedAssignees(ev.assignees)
|
setSelectedAssignees(ev.assignees)
|
||||||
setAttachments(ev.attachments)
|
setAttachments(ev.attachments)
|
||||||
setIsSystem(ev.calendar.is_system)
|
setIsSystem(ev.calendar.is_system)
|
||||||
|
setRrule(ev.rrule ?? null)
|
||||||
|
setIsRecurring(!!ev.is_recurring)
|
||||||
})
|
})
|
||||||
.catch(err => setError(err instanceof Error ? err.message : 'Failed to load event'))
|
.catch(err => setError(err instanceof Error ? err.message : 'Failed to load event'))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
|
// editingOccurrence/occurrenceStart are fixed for the lifetime of a given
|
||||||
|
// EventForm mount (a fresh instance is mounted per open), so this only
|
||||||
|
// ever runs once per event.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [eventId, editing])
|
}, [eventId, editing])
|
||||||
|
|
||||||
function toggleDept(dept: Department) {
|
function toggleDept(dept: Department) {
|
||||||
|
|
@ -106,30 +126,41 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!calendarId) { setError('Choose a calendar'); return }
|
if (!editingOccurrence && !calendarId) { setError('Choose a calendar'); return }
|
||||||
if (!title.trim()) { setError('Title is required'); return }
|
if (!title.trim()) { setError('Title is required'); return }
|
||||||
|
|
||||||
const start = allDay ? new Date(`${startDate}T00:00:00`) : new Date(`${startDate}T${startTime}:00`)
|
const start = allDay ? new Date(`${startDate}T00:00:00`) : new Date(`${startDate}T${startTime}:00`)
|
||||||
const end = allDay ? new Date(`${endDate}T23:59:00`) : new Date(`${endDate}T${endTime}:00`)
|
const end = allDay ? new Date(`${endDate}T23:59:00`) : new Date(`${endDate}T${endTime}:00`)
|
||||||
if (end.getTime() < start.getTime()) { setError('End must be after start'); return }
|
if (end.getTime() < start.getTime()) { setError('End must be after start'); return }
|
||||||
|
|
||||||
const body: EventBody = {
|
const body: Partial<EventBody> = {
|
||||||
calendar_id: calendarId,
|
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || null,
|
description: description.trim() || null,
|
||||||
location: location.trim() || null,
|
location: location.trim() || null,
|
||||||
start_at: start.toISOString(),
|
start_at: start.toISOString(),
|
||||||
end_at: end.toISOString(),
|
end_at: end.toISOString(),
|
||||||
all_day: allDay,
|
all_day: allDay,
|
||||||
departments: selectedDepts,
|
}
|
||||||
assignees: selectedAssignees,
|
// A 'this'-scoped edit never touches calendar/departments/assignees/rrule
|
||||||
|
// — those are series-level, only changeable via a scope:'all' edit.
|
||||||
|
if (!editingOccurrence) {
|
||||||
|
body.calendar_id = calendarId!
|
||||||
|
body.departments = selectedDepts
|
||||||
|
body.assignees = selectedAssignees
|
||||||
|
body.rrule = rrule
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
if (editing && eventId) await updateEvent(eventId, body)
|
if (editing && eventId) {
|
||||||
else await createEvent(body)
|
await updateEvent(eventId, body, {
|
||||||
|
occurrence_start: editingOccurrence ? occurrenceStart : undefined,
|
||||||
|
scope,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createEvent(body as EventBody)
|
||||||
|
}
|
||||||
onSaved()
|
onSaved()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -141,11 +172,19 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!eventId) return
|
if (!eventId) return
|
||||||
if (!confirm('Delete this event? This cannot be undone.')) return
|
const msg = editingOccurrence
|
||||||
|
? 'Delete this occurrence? Only this date is removed — the rest of the series is unaffected.'
|
||||||
|
: isRecurring
|
||||||
|
? 'Delete this event and every occurrence in its series? This cannot be undone.'
|
||||||
|
: 'Delete this event? This cannot be undone.'
|
||||||
|
if (!confirm(msg)) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await deleteEvent(eventId)
|
await deleteEvent(eventId, {
|
||||||
|
occurrence_start: editingOccurrence ? occurrenceStart : undefined,
|
||||||
|
scope,
|
||||||
|
})
|
||||||
onSaved()
|
onSaved()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -192,6 +231,14 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
|
{editingOccurrence && (
|
||||||
|
<div className="field-hint" style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}>
|
||||||
|
<Repeat size={13} strokeWidth={1.75} />
|
||||||
|
Editing this occurrence only — calendar, departments, assignees and the repeat rule are
|
||||||
|
set for the whole series and can only be changed there.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Title</label>
|
<label>Title</label>
|
||||||
<input type="text" value={title} onChange={e => setTitle(e.target.value)} disabled={!permitted} required />
|
<input type="text" value={title} onChange={e => setTitle(e.target.value)} disabled={!permitted} required />
|
||||||
|
|
@ -199,12 +246,16 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Calendar</label>
|
<label>Calendar</label>
|
||||||
<select value={calendarId ?? ''} onChange={e => setCalendarId(Number(e.target.value))} disabled={!permitted}>
|
{editingOccurrence ? (
|
||||||
<option value="" disabled>Choose a calendar…</option>
|
<div className="field-hint">{calendars.find(c => c.id === calendarId)?.name ?? '—'}</div>
|
||||||
{editableCalendars.map(c => (
|
) : (
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
<select value={calendarId ?? ''} onChange={e => setCalendarId(Number(e.target.value))} disabled={!permitted}>
|
||||||
))}
|
<option value="" disabled>Choose a calendar…</option>
|
||||||
</select>
|
{editableCalendars.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
|
|
@ -247,47 +298,65 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!editingOccurrence && (
|
||||||
|
<RecurrenceEditor initialRrule={rrule} onChange={setRrule} disabled={!permitted} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Departments</label>
|
<label>Departments</label>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
{editingOccurrence ? (
|
||||||
{departments.map(dept => (
|
<div className="chip-bar" style={{ margin: 0 }}>
|
||||||
<label key={dept.id} className="field-check">
|
{selectedDepts.length === 0 && <span className="field-hint">None</span>}
|
||||||
<input
|
{selectedDepts.map(d => <span key={d.id} className="badge badge-outline">{d.name}</span>)}
|
||||||
type="checkbox"
|
</div>
|
||||||
checked={selectedDepts.some(d => d.id === dept.id)}
|
) : (
|
||||||
onChange={() => toggleDept(dept)}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
disabled={!permitted}
|
{departments.map(dept => (
|
||||||
/>
|
<label key={dept.id} className="field-check">
|
||||||
{dept.name}
|
<input
|
||||||
</label>
|
type="checkbox"
|
||||||
))}
|
checked={selectedDepts.some(d => d.id === dept.id)}
|
||||||
{departments.length === 0 && <span className="field-hint">No departments configured.</span>}
|
onChange={() => toggleDept(dept)}
|
||||||
</div>
|
disabled={!permitted}
|
||||||
|
/>
|
||||||
|
{dept.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{departments.length === 0 && <span className="field-hint">No departments configured.</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Assignees</label>
|
<label>Assignees</label>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
{editingOccurrence ? (
|
||||||
{users.map(u => (
|
<div className="chip-bar" style={{ margin: 0 }}>
|
||||||
<label key={u.email} className="field-check">
|
{selectedAssignees.length === 0 && <span className="field-hint">None</span>}
|
||||||
<input
|
{selectedAssignees.map(a => <span key={a.email} className="badge badge-outline">{a.name}</span>)}
|
||||||
type="checkbox"
|
</div>
|
||||||
checked={selectedAssignees.some(a => a.email === u.email)}
|
) : (
|
||||||
onChange={() => toggleAssignee(u)}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
disabled={!permitted}
|
{users.map(u => (
|
||||||
/>
|
<label key={u.email} className="field-check">
|
||||||
{u.name}
|
<input
|
||||||
</label>
|
type="checkbox"
|
||||||
))}
|
checked={selectedAssignees.some(a => a.email === u.email)}
|
||||||
{users.length === 0 && <span className="field-hint">No staff available.</span>}
|
onChange={() => toggleAssignee(u)}
|
||||||
</div>
|
disabled={!permitted}
|
||||||
|
/>
|
||||||
|
{u.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{users.length === 0 && <span className="field-hint">No staff available.</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editing && eventId && (
|
{editing && eventId && (
|
||||||
<AttachmentList
|
<AttachmentList
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
canEdit={permitted}
|
canEdit={permitted && !editingOccurrence}
|
||||||
onChange={setAttachments}
|
onChange={setAttachments}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
32
frontend/src/components/OccurrenceScopePrompt.tsx
Normal file
32
frontend/src/components/OccurrenceScopePrompt.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Repeat } from 'lucide-react'
|
||||||
|
|
||||||
|
// Shown when the user tries to edit/delete an event that's an expanded
|
||||||
|
// occurrence of a recurring series (EventSummary.occurrence_start != null).
|
||||||
|
// Deliberately not a full modal (per spec) — just a small confirm-style
|
||||||
|
// overlay reusing .modal/.btn. There is no "this and future" option.
|
||||||
|
export default function OccurrenceScopePrompt({ title, onChoose, onCancel }: {
|
||||||
|
title: string
|
||||||
|
onChoose: (scope: 'this' | 'all') => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onCancel}>
|
||||||
|
<div className="modal" style={{ maxWidth: 380 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<Repeat size={16} strokeWidth={1.75} style={{ marginTop: 2 }} />
|
||||||
|
<h2>Recurring event</h2>
|
||||||
|
</div>
|
||||||
|
<p style={{ marginTop: 0, fontSize: 13.5, color: 'var(--text-mid)' }}>
|
||||||
|
"{title}" is part of a recurring series. What would you like to change?
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 }}>
|
||||||
|
<button className="btn" onClick={() => onChoose('this')}>This event</button>
|
||||||
|
<button className="btn btn-primary" onClick={() => onChoose('all')}>All events in the series</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn btn-sm" onClick={onCancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
204
frontend/src/components/RecurrenceEditor.tsx
Normal file
204
frontend/src/components/RecurrenceEditor.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { RRule } from 'rrule'
|
||||||
|
import { WEEKDAY_LABELS, toISODate } from '../dateUtils'
|
||||||
|
|
||||||
|
// RRule.js weekday constants (MO..SU) — their .weekday numeric value is
|
||||||
|
// 0=Mon..6=Sun, which matches WEEKDAY_LABELS' Monday-first ordering.
|
||||||
|
const RRULE_WEEKDAYS = [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR, RRule.SA, RRule.SU]
|
||||||
|
|
||||||
|
type Freq = 'DAILY' | 'WEEKLY' | 'MONTHLY'
|
||||||
|
type EndType = 'never' | 'count' | 'until'
|
||||||
|
|
||||||
|
interface BuilderState {
|
||||||
|
enabled: boolean
|
||||||
|
freq: Freq
|
||||||
|
interval: number
|
||||||
|
byweekday: Set<number>
|
||||||
|
endType: EndType
|
||||||
|
count: number
|
||||||
|
until: string // ISO date (yyyy-mm-dd)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FREQ_CODE: Record<Freq, number> = { DAILY: RRule.DAILY, WEEKLY: RRule.WEEKLY, MONTHLY: RRule.MONTHLY }
|
||||||
|
|
||||||
|
function parseInitial(rrule: string | null): BuilderState {
|
||||||
|
const fallback: BuilderState = {
|
||||||
|
enabled: false, freq: 'WEEKLY', interval: 1, byweekday: new Set(), endType: 'never', count: 10, until: '',
|
||||||
|
}
|
||||||
|
if (!rrule) return fallback
|
||||||
|
try {
|
||||||
|
const r = RRule.fromString(rrule)
|
||||||
|
const o = r.options
|
||||||
|
const freq: Freq = o.freq === RRule.DAILY ? 'DAILY' : o.freq === RRule.MONTHLY ? 'MONTHLY' : 'WEEKLY'
|
||||||
|
const byweekday = new Set<number>(Array.isArray(o.byweekday) ? o.byweekday : [])
|
||||||
|
const endType: EndType = o.count != null ? 'count' : o.until ? 'until' : 'never'
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
freq,
|
||||||
|
interval: o.interval || 1,
|
||||||
|
byweekday,
|
||||||
|
endType,
|
||||||
|
count: o.count ?? 10,
|
||||||
|
until: o.until ? toISODate(new Date(o.until)) : '',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the bare RRULE property value (no "RRULE:" prefix, no DTSTART line)
|
||||||
|
// that the backend expects — see EventForm.tsx for the contract note.
|
||||||
|
function buildRruleString(s: BuilderState): string | null {
|
||||||
|
if (!s.enabled) return null
|
||||||
|
const opts: ConstructorParameters<typeof RRule>[0] = {
|
||||||
|
freq: FREQ_CODE[s.freq],
|
||||||
|
interval: Math.max(1, s.interval || 1),
|
||||||
|
}
|
||||||
|
if (s.freq === 'WEEKLY' && s.byweekday.size > 0) {
|
||||||
|
opts.byweekday = [...s.byweekday].sort().map(i => RRULE_WEEKDAYS[i])
|
||||||
|
}
|
||||||
|
if (s.endType === 'count') opts.count = Math.max(1, s.count || 1)
|
||||||
|
if (s.endType === 'until' && s.until) opts.until = new Date(`${s.until}T23:59:59Z`)
|
||||||
|
return new RRule(opts).toString().replace(/^RRULE:/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecurrenceEditor({ initialRrule, onChange, disabled }: {
|
||||||
|
initialRrule: string | null
|
||||||
|
onChange: (rrule: string | null) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
// Lazy-initialised once from the loaded event's rrule — this component is
|
||||||
|
// only ever mounted after the event's data has finished loading (or, for a
|
||||||
|
// new event, with initialRrule=null), so it never needs to re-sync later.
|
||||||
|
const [state, setState] = useState<BuilderState>(() => parseInitial(initialRrule))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange(buildRruleString(state))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [state.enabled, state.freq, state.interval, state.endType, state.count, state.until, state.byweekday])
|
||||||
|
|
||||||
|
function toggleDay(i: number) {
|
||||||
|
setState(s => {
|
||||||
|
const next = new Set(s.byweekday)
|
||||||
|
if (next.has(i)) next.delete(i)
|
||||||
|
else next.add(i)
|
||||||
|
return { ...s, byweekday: next }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-check" style={{ marginBottom: state.enabled ? 8 : 0 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={state.enabled}
|
||||||
|
onChange={e => setState(s => ({ ...s, enabled: e.target.checked }))}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
Repeat
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{state.enabled && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, paddingLeft: 2 }}>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
|
<label>Frequency</label>
|
||||||
|
<select
|
||||||
|
value={state.freq}
|
||||||
|
onChange={e => setState(s => ({ ...s, freq: e.target.value as Freq }))}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<option value="DAILY">Daily</option>
|
||||||
|
<option value="WEEKLY">Weekly</option>
|
||||||
|
<option value="MONTHLY">Monthly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
|
<label>Every</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={state.interval}
|
||||||
|
onChange={e => setState(s => ({ ...s, interval: parseInt(e.target.value) || 1 }))}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className="field-hint">
|
||||||
|
{state.freq === 'DAILY' ? 'day(s)' : state.freq === 'WEEKLY' ? 'week(s)' : 'month(s)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.freq === 'WEEKLY' && (
|
||||||
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
|
<label>On days</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{WEEKDAY_LABELS.map((lbl, i) => (
|
||||||
|
<span
|
||||||
|
key={lbl}
|
||||||
|
className={`chip ${state.byweekday.has(i) ? 'active' : ''}`}
|
||||||
|
onClick={() => !disabled && toggleDay(i)}
|
||||||
|
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
{lbl}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
|
<label>Ends</label>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<label className="field-check">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="rrule-end"
|
||||||
|
checked={state.endType === 'never'}
|
||||||
|
onChange={() => setState(s => ({ ...s, endType: 'never' }))}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
Never
|
||||||
|
</label>
|
||||||
|
<label className="field-check">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="rrule-end"
|
||||||
|
checked={state.endType === 'count'}
|
||||||
|
onChange={() => setState(s => ({ ...s, endType: 'count' }))}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
After
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={state.count}
|
||||||
|
onChange={e => setState(s => ({ ...s, endType: 'count', count: parseInt(e.target.value) || 1 }))}
|
||||||
|
disabled={disabled || state.endType !== 'count'}
|
||||||
|
style={{ width: 64 }}
|
||||||
|
/>
|
||||||
|
occurrences
|
||||||
|
</label>
|
||||||
|
<label className="field-check">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="rrule-end"
|
||||||
|
checked={state.endType === 'until'}
|
||||||
|
onChange={() => setState(s => ({ ...s, endType: 'until' }))}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
On date
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={state.until}
|
||||||
|
onChange={e => setState(s => ({ ...s, endType: 'until', until: e.target.value }))}
|
||||||
|
disabled={disabled || state.endType !== 'until'}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ export default function AgendaList({ events, onSelectEvent }: {
|
||||||
events: EventSummary[]
|
events: EventSummary[]
|
||||||
date: Date
|
date: Date
|
||||||
onSelectDate?: (d: Date) => void
|
onSelectDate?: (d: Date) => void
|
||||||
onSelectEvent: (id: number) => void
|
onSelectEvent: (ev: EventSummary) => void
|
||||||
}) {
|
}) {
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@ export default function AgendaList({ events, onSelectEvent }: {
|
||||||
<div key={dayKey}>
|
<div key={dayKey}>
|
||||||
<div className="section-title">{formatDayHeader(new Date(dayKey))}</div>
|
<div className="section-title">{formatDayHeader(new Date(dayKey))}</div>
|
||||||
{dayEvents.map(ev => (
|
{dayEvents.map(ev => (
|
||||||
<div key={ev.id} className="cal-agenda-item" onClick={() => onSelectEvent(ev.id)}>
|
<div key={ev.id} className="cal-agenda-item" onClick={() => onSelectEvent(ev)}>
|
||||||
<div className="cal-agenda-date">{ev.all_day ? 'All day' : formatTime(ev.start_at)}</div>
|
<div className="cal-agenda-date">{ev.all_day ? 'All day' : formatTime(ev.start_at)}</div>
|
||||||
<div className="cal-agenda-main">
|
<div className="cal-agenda-main">
|
||||||
<div className="cal-agenda-title">
|
<div className="cal-agenda-title">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: {
|
||||||
events: EventSummary[]
|
events: EventSummary[]
|
||||||
date: Date
|
date: Date
|
||||||
onSelectDate?: (d: Date) => void
|
onSelectDate?: (d: Date) => void
|
||||||
onSelectEvent: (id: number) => void
|
onSelectEvent: (ev: EventSummary) => void
|
||||||
}) {
|
}) {
|
||||||
const allDayEvents = events.filter(ev => ev.all_day && eventOccursOnDay(ev, date))
|
const allDayEvents = events.filter(ev => ev.all_day && eventOccursOnDay(ev, date))
|
||||||
const timedEvents = events.filter(ev => !ev.all_day && eventOccursOnDay(ev, date))
|
const timedEvents = events.filter(ev => !ev.all_day && eventOccursOnDay(ev, date))
|
||||||
|
|
@ -28,7 +28,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: {
|
||||||
key={ev.id}
|
key={ev.id}
|
||||||
className="cal-event-chip"
|
className="cal-event-chip"
|
||||||
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
|
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
|
||||||
onClick={() => onSelectEvent(ev.id)}
|
onClick={() => onSelectEvent(ev)}
|
||||||
>
|
>
|
||||||
{ev.title}
|
{ev.title}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -53,7 +53,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: {
|
||||||
key={ev.id}
|
key={ev.id}
|
||||||
className="cal-week-event"
|
className="cal-week-event"
|
||||||
style={{ top, height, background: ev.calendar_color, color: '#fff' }}
|
style={{ top, height, background: ev.calendar_color, color: '#fff' }}
|
||||||
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }}
|
onClick={e => { e.stopPropagation(); onSelectEvent(ev) }}
|
||||||
title={ev.title}
|
title={ev.title}
|
||||||
>
|
>
|
||||||
{formatTime(ev.start_at)} {ev.title}
|
{formatTime(ev.start_at)} {ev.title}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export default function MonthGrid({ events, date, onSelectDate, onSelectEvent }:
|
||||||
events: EventSummary[]
|
events: EventSummary[]
|
||||||
date: Date
|
date: Date
|
||||||
onSelectDate?: (d: Date) => void
|
onSelectDate?: (d: Date) => void
|
||||||
onSelectEvent: (id: number) => void
|
onSelectEvent: (ev: EventSummary) => void
|
||||||
}) {
|
}) {
|
||||||
const days = monthGridDays(date)
|
const days = monthGridDays(date)
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
|
|
@ -35,7 +35,7 @@ export default function MonthGrid({ events, date, onSelectDate, onSelectEvent }:
|
||||||
className="cal-event-chip"
|
className="cal-event-chip"
|
||||||
style={{ background: ev.calendar_color, color: '#fff' }}
|
style={{ background: ev.calendar_color, color: '#fff' }}
|
||||||
title={ev.title}
|
title={ev.title}
|
||||||
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }}
|
onClick={e => { e.stopPropagation(); onSelectEvent(ev) }}
|
||||||
>
|
>
|
||||||
{!ev.all_day && `${formatTime(ev.start_at)} `}{ev.title}
|
{!ev.all_day && `${formatTime(ev.start_at)} `}{ev.title}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }:
|
||||||
events: EventSummary[]
|
events: EventSummary[]
|
||||||
date: Date
|
date: Date
|
||||||
onSelectDate?: (d: Date) => void
|
onSelectDate?: (d: Date) => void
|
||||||
onSelectEvent: (id: number) => void
|
onSelectEvent: (ev: EventSummary) => void
|
||||||
}) {
|
}) {
|
||||||
const weekStart = startOfWeek(date)
|
const weekStart = startOfWeek(date)
|
||||||
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
||||||
|
|
@ -18,64 +18,66 @@ export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }:
|
||||||
const timedByDay = days.map(day => events.filter(ev => !ev.all_day && eventOccursOnDay(ev, day)))
|
const timedByDay = days.map(day => events.filter(ev => !ev.all_day && eventOccursOnDay(ev, day)))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cal-week-grid">
|
<div className="cal-week-scroll">
|
||||||
<div className="cal-week-head-cell" />
|
<div className="cal-week-grid">
|
||||||
{days.map(day => (
|
<div className="cal-week-head-cell" />
|
||||||
<div
|
{days.map(day => (
|
||||||
key={day.toISOString()}
|
<div
|
||||||
className={`cal-week-head-cell${isSameDay(day, today) ? ' today' : ''}`}
|
key={day.toISOString()}
|
||||||
onClick={() => onSelectDate?.(day)}
|
className={`cal-week-head-cell${isSameDay(day, today) ? ' today' : ''}`}
|
||||||
style={{ cursor: 'pointer' }}
|
onClick={() => onSelectDate?.(day)}
|
||||||
>
|
style={{ cursor: 'pointer' }}
|
||||||
{formatDayHeader(day)}
|
>
|
||||||
</div>
|
{formatDayHeader(day)}
|
||||||
))}
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
<div />
|
<div />
|
||||||
{days.map((day, i) => (
|
{days.map((day, i) => (
|
||||||
<div key={`allday-${day.toISOString()}`} style={{ borderLeft: '1px solid var(--card-border)', padding: '3px' }}>
|
<div key={`allday-${day.toISOString()}`} style={{ borderLeft: '1px solid var(--card-border)', padding: '3px' }}>
|
||||||
{allDayByDay[i].map(ev => (
|
{allDayByDay[i].map(ev => (
|
||||||
<span
|
<span
|
||||||
key={ev.id}
|
key={ev.id}
|
||||||
className="cal-event-chip"
|
className="cal-event-chip"
|
||||||
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
|
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
|
||||||
onClick={() => onSelectEvent(ev.id)}
|
onClick={() => onSelectEvent(ev)}
|
||||||
>
|
>
|
||||||
{ev.title}
|
{ev.title}
|
||||||
</span>
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="cal-time-gutter">
|
||||||
|
{HOURS.map(h => (
|
||||||
|
<div key={h} className="cal-time-row">{h === 0 ? '' : `${h}:00`}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
{days.map((day, i) => (
|
||||||
|
<div
|
||||||
<div className="cal-time-gutter">
|
key={`col-${day.toISOString()}`}
|
||||||
{HOURS.map(h => (
|
className="cal-day-col"
|
||||||
<div key={h} className="cal-time-row">{h === 0 ? '' : `${h}:00`}</div>
|
style={{ height: HOURS.length * HOUR_PX }}
|
||||||
|
onClick={() => onSelectDate?.(day)}
|
||||||
|
>
|
||||||
|
{HOURS.map(h => <div key={h} className="cal-day-col-slot" />)}
|
||||||
|
{timedByDay[i].map(ev => {
|
||||||
|
const { top, height } = timedLayout(ev, day)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ev.id}
|
||||||
|
className="cal-week-event"
|
||||||
|
style={{ top, height, background: ev.calendar_color, color: '#fff' }}
|
||||||
|
onClick={e => { e.stopPropagation(); onSelectEvent(ev) }}
|
||||||
|
title={ev.title}
|
||||||
|
>
|
||||||
|
{formatTime(ev.start_at)} {ev.title}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{days.map((day, i) => (
|
|
||||||
<div
|
|
||||||
key={`col-${day.toISOString()}`}
|
|
||||||
className="cal-day-col"
|
|
||||||
style={{ height: HOURS.length * HOUR_PX }}
|
|
||||||
onClick={() => onSelectDate?.(day)}
|
|
||||||
>
|
|
||||||
{HOURS.map(h => <div key={h} className="cal-day-col-slot" />)}
|
|
||||||
{timedByDay[i].map(ev => {
|
|
||||||
const { top, height } = timedLayout(ev, day)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={ev.id}
|
|
||||||
className="cal-week-event"
|
|
||||||
style={{ top, height, background: ev.calendar_color, color: '#fff' }}
|
|
||||||
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }}
|
|
||||||
title={ev.title}
|
|
||||||
>
|
|
||||||
{formatTime(ev.start_at)} {ev.title}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
frontend/src/hooks/usePushSubscription.ts
Normal file
57
frontend/src/hooks/usePushSubscription.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
const BASE = '/calendar/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])
|
||||||
|
}
|
||||||
|
|
@ -121,6 +121,21 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
|
||||||
.top-bar { display: flex; }
|
.top-bar { display: flex; }
|
||||||
.app-shell { flex-direction: column; }
|
.app-shell { flex-direction: column; }
|
||||||
.field-row { flex-direction: column; }
|
.field-row { flex-direction: column; }
|
||||||
|
|
||||||
|
/* Calendar page: stack the Calendars filter above the grid instead of
|
||||||
|
squeezing both into a shared row — flex-basis:0 on .cal-main means
|
||||||
|
flex-wrap alone never triggers here, so force it with flex-direction. */
|
||||||
|
.cal-layout { flex-direction: column; }
|
||||||
|
.cal-sidebar-card { width: 100%; }
|
||||||
|
|
||||||
|
/* Bigger tap targets for controls staff hit repeatedly on a phone */
|
||||||
|
.btn-sm { padding: 8px 12px; font-size: 13px; }
|
||||||
|
.chip { padding: 8px 14px; }
|
||||||
|
.cal-event-chip { padding: 4px 8px; }
|
||||||
|
|
||||||
|
/* Let the week grid keep usable column widths and scroll horizontally
|
||||||
|
instead of compressing 7 days into an unreadable strip */
|
||||||
|
.cal-week-scroll .cal-week-grid:not(.cal-day-view) { min-width: 640px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-backdrop {
|
.menu-backdrop {
|
||||||
|
|
@ -401,6 +416,14 @@ table.data tr.clickable:hover td { background: var(--body-bg); }
|
||||||
Calendar-specific components
|
Calendar-specific components
|
||||||
══════════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── Calendar page layout ──────────────────────────────────────── */
|
||||||
|
.cal-layout { display: flex; gap: 16px; align-items: flex-start; }
|
||||||
|
.cal-sidebar-card { width: 220px; flex-shrink: 0; }
|
||||||
|
.cal-main { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.cal-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.cal-toolbar-label { font-weight: 600; font-size: 14px; flex: 1; min-width: 140px; }
|
||||||
|
|
||||||
/* ── View switcher (reuses .chip/.chip.active) ────────────────── */
|
/* ── View switcher (reuses .chip/.chip.active) ────────────────── */
|
||||||
.cal-view-switcher { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
.cal-view-switcher { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||||
|
|
||||||
|
|
@ -488,6 +511,7 @@ table.data tr.clickable:hover td { background: var(--body-bg); }
|
||||||
.cal-event-more { font-size: 11px; color: var(--text-mid); padding: 1px 6px; cursor: pointer; }
|
.cal-event-more { font-size: 11px; color: var(--text-mid); padding: 1px 6px; cursor: pointer; }
|
||||||
|
|
||||||
/* ── Week / day grid ───────────────────────────────────────────── */
|
/* ── Week / day grid ───────────────────────────────────────────── */
|
||||||
|
.cal-week-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
.cal-week-grid {
|
.cal-week-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 56px repeat(7, 1fr);
|
grid-template-columns: 56px repeat(7, 1fr);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,18 @@
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react'
|
import { Bell, BellOff, Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react'
|
||||||
import type { CaldavCredential, CaldavCredentialCreated } from '../types'
|
import type { CaldavCredential, CaldavCredentialCreated } from '../types'
|
||||||
import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential } from '../api'
|
import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential, fetchVapidKey, subscribePush, unsubscribePush } from '../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
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushState = 'unsupported' | 'denied' | 'off' | 'on' | 'checking'
|
||||||
|
|
||||||
export default function CalDavSetup() {
|
export default function CalDavSetup() {
|
||||||
const [credentials, setCredentials] = useState<CaldavCredential[]>([])
|
const [credentials, setCredentials] = useState<CaldavCredential[]>([])
|
||||||
|
|
@ -11,6 +22,74 @@ export default function CalDavSetup() {
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [revealed, setRevealed] = useState<CaldavCredentialCreated | null>(null)
|
const [revealed, setRevealed] = useState<CaldavCredentialCreated | null>(null)
|
||||||
|
|
||||||
|
const [pushState, setPushState] = useState<PushState>('checking')
|
||||||
|
const [pushBusy, setPushBusy] = useState(false)
|
||||||
|
const [pushError, setPushError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const refreshPushState = useCallback(async () => {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
|
setPushState('unsupported')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Notification.permission === 'denied') {
|
||||||
|
setPushState('denied')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.ready
|
||||||
|
const sub = await reg.pushManager.getSubscription()
|
||||||
|
setPushState(sub ? 'on' : 'off')
|
||||||
|
} catch {
|
||||||
|
setPushState('off')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { refreshPushState() }, [refreshPushState])
|
||||||
|
|
||||||
|
async function enablePush() {
|
||||||
|
setPushBusy(true)
|
||||||
|
setPushError(null)
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.ready
|
||||||
|
if (Notification.permission === 'default') {
|
||||||
|
const permission = await Notification.requestPermission()
|
||||||
|
if (permission !== 'granted') { setPushState('denied'); return }
|
||||||
|
} else if (Notification.permission === 'denied') {
|
||||||
|
setPushState('denied')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { publicKey } = await fetchVapidKey()
|
||||||
|
const sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||||
|
})
|
||||||
|
await subscribePush(sub.toJSON())
|
||||||
|
setPushState('on')
|
||||||
|
} catch (err) {
|
||||||
|
setPushError(err instanceof Error ? err.message : 'Failed to enable push notifications')
|
||||||
|
} finally {
|
||||||
|
setPushBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disablePush() {
|
||||||
|
setPushBusy(true)
|
||||||
|
setPushError(null)
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.ready
|
||||||
|
const sub = await reg.pushManager.getSubscription()
|
||||||
|
if (sub) {
|
||||||
|
await unsubscribePush(sub.endpoint)
|
||||||
|
await sub.unsubscribe()
|
||||||
|
}
|
||||||
|
setPushState('off')
|
||||||
|
} catch (err) {
|
||||||
|
setPushError(err instanceof Error ? err.message : 'Failed to disable push notifications')
|
||||||
|
} finally {
|
||||||
|
setPushBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const caldavUrl = `${window.location.origin}/calendar/caldav/`
|
const caldavUrl = `${window.location.origin}/calendar/caldav/`
|
||||||
|
|
||||||
const reload = useCallback(() => {
|
const reload = useCallback(() => {
|
||||||
|
|
@ -72,6 +151,34 @@ export default function CalDavSetup() {
|
||||||
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<div className="section-title">Push notifications</div>
|
||||||
|
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
{pushState === 'on' ? (
|
||||||
|
<Bell size={18} strokeWidth={1.75} color="var(--gold)" />
|
||||||
|
) : (
|
||||||
|
<BellOff size={18} strokeWidth={1.75} style={{ color: 'var(--text-mid)' }} />
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13.5 }}>
|
||||||
|
{pushState === 'unsupported' && 'Not supported on this device/browser'}
|
||||||
|
{pushState === 'denied' && 'Blocked — enable notifications for this site in your browser settings'}
|
||||||
|
{pushState === 'checking' && 'Checking status…'}
|
||||||
|
{pushState === 'off' && 'Notifications are off on this device'}
|
||||||
|
{pushState === 'on' && 'Notifications are on for this device'}
|
||||||
|
</div>
|
||||||
|
{pushError && <div className="field-hint" style={{ color: 'var(--danger)' }}>{pushError}</div>}
|
||||||
|
</div>
|
||||||
|
{(pushState === 'off' || pushState === 'on') && (
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${pushState === 'off' ? 'btn-primary' : ''}`}
|
||||||
|
onClick={pushState === 'off' ? enablePush : disablePush}
|
||||||
|
disabled={pushBusy}
|
||||||
|
>
|
||||||
|
{pushBusy ? 'Working…' : pushState === 'off' ? 'Enable' : 'Disable'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="section-title">Your CalDAV devices</div>
|
<div className="section-title">Your CalDAV devices</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty-state">Loading…</div>
|
<div className="empty-state">Loading…</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { Lock, Pencil, Plus, Trash2, X } from 'lucide-react'
|
import { Lock, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||||
import type { Calendar } from '../types'
|
import type { Calendar, AppConfig } from '../types'
|
||||||
import { fetchCalendars, createCalendar, updateCalendar, deleteCalendar } from '../api'
|
import { can } from '../types'
|
||||||
|
import { useAuth } from '../components/AuthGate'
|
||||||
|
import { fetchCalendars, createCalendar, updateCalendar, deleteCalendar, fetchConfig, updateConfig } from '../api'
|
||||||
|
|
||||||
// Curated swatch — similarly saturated hues that read well against the navy/gold theme.
|
// Curated swatch — similarly saturated hues that read well against the navy/gold theme.
|
||||||
const SWATCHES = [
|
const SWATCHES = [
|
||||||
|
|
@ -10,10 +12,17 @@ const SWATCHES = [
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function CalendarSettings() {
|
export default function CalendarSettings() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isAdmin = can(user, 'admin')
|
||||||
|
|
||||||
const [calendars, setCalendars] = useState<Calendar[]>([])
|
const [calendars, setCalendars] = useState<Calendar[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||||
|
const [configError, setConfigError] = useState<string | null>(null)
|
||||||
|
const [configSavingKey, setConfigSavingKey] = useState<string | null>(null)
|
||||||
|
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [editName, setEditName] = useState('')
|
const [editName, setEditName] = useState('')
|
||||||
const [editColor, setEditColor] = useState(SWATCHES[0])
|
const [editColor, setEditColor] = useState(SWATCHES[0])
|
||||||
|
|
@ -31,6 +40,27 @@ export default function CalendarSettings() {
|
||||||
|
|
||||||
useEffect(() => { reload() }, [reload])
|
useEffect(() => { reload() }, [reload])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) return
|
||||||
|
fetchConfig().then(setConfig).catch(err => setConfigError(err instanceof Error ? err.message : 'Failed to load config'))
|
||||||
|
}, [isAdmin])
|
||||||
|
|
||||||
|
async function saveConfig(key: string, value: unknown) {
|
||||||
|
if (!config) return
|
||||||
|
setConfigSavingKey(key)
|
||||||
|
setConfigError(null)
|
||||||
|
const previous = config
|
||||||
|
setConfig({ ...config, [key]: value })
|
||||||
|
try {
|
||||||
|
await updateConfig(key, value)
|
||||||
|
} catch (err) {
|
||||||
|
setConfig(previous)
|
||||||
|
setConfigError(err instanceof Error ? err.message : 'Failed to save setting')
|
||||||
|
} finally {
|
||||||
|
setConfigSavingKey(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startEdit(cal: Calendar) {
|
function startEdit(cal: Calendar) {
|
||||||
setEditingId(cal.id)
|
setEditingId(cal.id)
|
||||||
setEditName(cal.name)
|
setEditName(cal.name)
|
||||||
|
|
@ -195,6 +225,47 @@ export default function CalendarSettings() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<div className="section-title">Notifications</div>
|
||||||
|
{configError && <div className="error-banner">{configError}</div>}
|
||||||
|
{!config ? (
|
||||||
|
<div className="empty-state">Loading…</div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<label className="field-check" style={{ marginBottom: 12 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!config.notify_on_assignment}
|
||||||
|
onChange={e => saveConfig('notify_on_assignment', e.target.checked)}
|
||||||
|
disabled={configSavingKey !== null}
|
||||||
|
/>
|
||||||
|
Notify staff when they're assigned to an event
|
||||||
|
</label>
|
||||||
|
<label className="field-check" style={{ marginBottom: 12 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!config.notify_reminders}
|
||||||
|
onChange={e => saveConfig('notify_reminders', e.target.checked)}
|
||||||
|
disabled={configSavingKey !== null}
|
||||||
|
/>
|
||||||
|
Send reminder notifications before events start
|
||||||
|
</label>
|
||||||
|
<div className="field" style={{ maxWidth: 220, marginBottom: 0 }}>
|
||||||
|
<label>Reminder lead time (minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={config.reminder_minutes_before}
|
||||||
|
onChange={e => saveConfig('reminder_minutes_before', parseInt(e.target.value) || 0)}
|
||||||
|
disabled={configSavingKey !== null || !config.notify_reminders}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { fetchCalendars, fetchEvents } from '../api'
|
||||||
import CalendarToggleList from '../components/CalendarToggleList'
|
import CalendarToggleList from '../components/CalendarToggleList'
|
||||||
import ViewSwitcher, { type CalendarViewKey } from '../components/ViewSwitcher'
|
import ViewSwitcher, { type CalendarViewKey } from '../components/ViewSwitcher'
|
||||||
import EventForm from '../components/EventForm'
|
import EventForm from '../components/EventForm'
|
||||||
|
import OccurrenceScopePrompt from '../components/OccurrenceScopePrompt'
|
||||||
import MonthGrid from '../components/views/MonthGrid'
|
import MonthGrid from '../components/views/MonthGrid'
|
||||||
import WeekGrid from '../components/views/WeekGrid'
|
import WeekGrid from '../components/views/WeekGrid'
|
||||||
import DayGrid from '../components/views/DayGrid'
|
import DayGrid from '../components/views/DayGrid'
|
||||||
|
|
@ -55,7 +56,14 @@ export default function CalendarView() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [formState, setFormState] = useState<{ open: boolean; eventId?: number; initialDate?: Date }>({ open: false })
|
const [formState, setFormState] = useState<{
|
||||||
|
open: boolean
|
||||||
|
eventId?: number
|
||||||
|
initialDate?: Date
|
||||||
|
occurrenceStart?: string
|
||||||
|
scope?: 'this' | 'all'
|
||||||
|
}>({ open: false })
|
||||||
|
const [scopePromptEvent, setScopePromptEvent] = useState<EventSummary | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCalendars().then(cals => {
|
fetchCalendars().then(cals => {
|
||||||
|
|
@ -91,8 +99,26 @@ export default function CalendarView() {
|
||||||
if (!can(user, 'create')) return
|
if (!can(user, 'create')) return
|
||||||
setFormState({ open: true, initialDate: date })
|
setFormState({ open: true, initialDate: date })
|
||||||
}
|
}
|
||||||
function openEdit(id: number) {
|
// Recurring occurrences (occurrence_start != null) need the user to choose
|
||||||
setFormState({ open: true, eventId: id })
|
// "this event" vs "the whole series" before we know what to fetch/save —
|
||||||
|
// a plain, non-recurring event skips straight to the form as before.
|
||||||
|
function openEdit(ev: EventSummary) {
|
||||||
|
if (ev.occurrence_start != null) {
|
||||||
|
setScopePromptEvent(ev)
|
||||||
|
} else {
|
||||||
|
setFormState({ open: true, eventId: ev.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function chooseScope(scope: 'this' | 'all') {
|
||||||
|
const ev = scopePromptEvent
|
||||||
|
setScopePromptEvent(null)
|
||||||
|
if (!ev) return
|
||||||
|
setFormState({
|
||||||
|
open: true,
|
||||||
|
eventId: ev.id,
|
||||||
|
occurrenceStart: scope === 'this' ? (ev.occurrence_start ?? undefined) : undefined,
|
||||||
|
scope,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
function closeForm() {
|
function closeForm() {
|
||||||
setFormState({ open: false })
|
setFormState({ open: false })
|
||||||
|
|
@ -119,14 +145,14 @@ export default function CalendarView() {
|
||||||
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
<div className="cal-layout">
|
||||||
<div className="card" style={{ width: 220, flexShrink: 0 }}>
|
<div className="card cal-sidebar-card">
|
||||||
<div className="section-title" style={{ marginTop: 0 }}>Calendars</div>
|
<div className="section-title" style={{ marginTop: 0 }}>Calendars</div>
|
||||||
<CalendarToggleList calendars={calendars} visibleIds={visibleIds} onToggle={toggleCalendar} />
|
<CalendarToggleList calendars={calendars} visibleIds={visibleIds} onToggle={toggleCalendar} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div className="cal-main">
|
||||||
<div className="filter-row" style={{ marginBottom: 12, alignItems: 'center' }}>
|
<div className="cal-toolbar">
|
||||||
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, -1))}>
|
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, -1))}>
|
||||||
<ChevronLeft size={14} strokeWidth={1.75} />
|
<ChevronLeft size={14} strokeWidth={1.75} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -134,7 +160,7 @@ export default function CalendarView() {
|
||||||
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, 1))}>
|
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, 1))}>
|
||||||
<ChevronRight size={14} strokeWidth={1.75} />
|
<ChevronRight size={14} strokeWidth={1.75} />
|
||||||
</button>
|
</button>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, flex: 1 }}>{labelFor(view, currentDate)}</div>
|
<div className="cal-toolbar-label">{labelFor(view, currentDate)}</div>
|
||||||
<ViewSwitcher view={view} onChange={setView} />
|
<ViewSwitcher view={view} onChange={setView} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -156,10 +182,20 @@ export default function CalendarView() {
|
||||||
<EventForm
|
<EventForm
|
||||||
eventId={formState.eventId}
|
eventId={formState.eventId}
|
||||||
initialDate={formState.initialDate}
|
initialDate={formState.initialDate}
|
||||||
|
occurrenceStart={formState.occurrenceStart}
|
||||||
|
scope={formState.scope}
|
||||||
onClose={closeForm}
|
onClose={closeForm}
|
||||||
onSaved={reload}
|
onSaved={reload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{scopePromptEvent && (
|
||||||
|
<OccurrenceScopePrompt
|
||||||
|
title={scopePromptEvent.title}
|
||||||
|
onChoose={chooseScope}
|
||||||
|
onCancel={() => setScopePromptEvent(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { CalendarClock, Users2 } from 'lucide-react'
|
||||||
import type { Department, EventSummary } from '../types'
|
import type { Department, EventSummary } from '../types'
|
||||||
import { fetchMyUpcoming, fetchMyDepartments } from '../api'
|
import { fetchMyUpcoming, fetchMyDepartments } from '../api'
|
||||||
import EventForm from '../components/EventForm'
|
import EventForm from '../components/EventForm'
|
||||||
|
import OccurrenceScopePrompt from '../components/OccurrenceScopePrompt'
|
||||||
import { formatDayHeader, formatTime } from '../dateUtils'
|
import { formatDayHeader, formatTime } from '../dateUtils'
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
|
|
@ -10,7 +11,12 @@ export default function Dashboard() {
|
||||||
const [departments, setDepartments] = useState<Department[]>([])
|
const [departments, setDepartments] = useState<Department[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [openEventId, setOpenEventId] = useState<number | null>(null)
|
const [formState, setFormState] = useState<{
|
||||||
|
eventId: number
|
||||||
|
occurrenceStart?: string
|
||||||
|
scope?: 'this' | 'all'
|
||||||
|
} | null>(null)
|
||||||
|
const [scopePromptEvent, setScopePromptEvent] = useState<EventSummary | null>(null)
|
||||||
|
|
||||||
const reload = useCallback(() => {
|
const reload = useCallback(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -23,6 +29,24 @@ export default function Dashboard() {
|
||||||
|
|
||||||
useEffect(() => { reload() }, [reload])
|
useEffect(() => { reload() }, [reload])
|
||||||
|
|
||||||
|
function openEvent(ev: EventSummary) {
|
||||||
|
if (ev.occurrence_start != null) {
|
||||||
|
setScopePromptEvent(ev)
|
||||||
|
} else {
|
||||||
|
setFormState({ eventId: ev.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function chooseScope(scope: 'this' | 'all') {
|
||||||
|
const ev = scopePromptEvent
|
||||||
|
setScopePromptEvent(null)
|
||||||
|
if (!ev) return
|
||||||
|
setFormState({
|
||||||
|
eventId: ev.id,
|
||||||
|
occurrenceStart: scope === 'this' ? (ev.occurrence_start ?? undefined) : undefined,
|
||||||
|
scope,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
|
|
@ -63,7 +87,7 @@ export default function Dashboard() {
|
||||||
<div className="empty-state">Nothing on your calendar in the next 7 days.</div>
|
<div className="empty-state">Nothing on your calendar in the next 7 days.</div>
|
||||||
) : (
|
) : (
|
||||||
upcoming.map(ev => (
|
upcoming.map(ev => (
|
||||||
<div key={ev.id} className="card task-card" onClick={() => setOpenEventId(ev.id)}>
|
<div key={ev.id} className="card task-card" onClick={() => openEvent(ev)}>
|
||||||
<CalendarClock size={16} strokeWidth={1.75} color={ev.calendar_color} style={{ marginTop: 2 }} />
|
<CalendarClock size={16} strokeWidth={1.75} color={ev.calendar_color} style={{ marginTop: 2 }} />
|
||||||
<div className="task-card-main">
|
<div className="task-card-main">
|
||||||
<div className="task-card-title">{ev.title}</div>
|
<div className="task-card-title">{ev.title}</div>
|
||||||
|
|
@ -77,13 +101,23 @@ export default function Dashboard() {
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{openEventId !== null && (
|
{formState && (
|
||||||
<EventForm
|
<EventForm
|
||||||
eventId={openEventId}
|
eventId={formState.eventId}
|
||||||
onClose={() => setOpenEventId(null)}
|
occurrenceStart={formState.occurrenceStart}
|
||||||
|
scope={formState.scope}
|
||||||
|
onClose={() => setFormState(null)}
|
||||||
onSaved={reload}
|
onSaved={reload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{scopePromptEvent && (
|
||||||
|
<OccurrenceScopePrompt
|
||||||
|
title={scopePromptEvent.title}
|
||||||
|
onChoose={chooseScope}
|
||||||
|
onCancel={() => setScopePromptEvent(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,27 @@ registerRoute(
|
||||||
denylist: [/\/api\//],
|
denylist: [/\/api\//],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.addEventListener('push', event => {
|
||||||
|
const data = event.data?.json() ?? {}
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || 'Calendar', {
|
||||||
|
body: data.body || '',
|
||||||
|
icon: '/calendar/icons/icon-192.png',
|
||||||
|
badge: '/calendar/icons/icon-192.png',
|
||||||
|
data: { url: data.url || '/calendar/' },
|
||||||
|
tag: data.tag || 'calendar',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', event => {
|
||||||
|
event.notification.close()
|
||||||
|
const url = event.notification.data?.url || '/calendar/'
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window' }).then(list => {
|
||||||
|
const existing = list.find(c => c.url.includes('/calendar/') && 'focus' in c)
|
||||||
|
return existing ? existing.focus() : clients.openWindow(url)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ export interface EventSummary {
|
||||||
department_names: string[]
|
department_names: string[]
|
||||||
assignee_names: string[]
|
assignee_names: string[]
|
||||||
attachment_count: number
|
attachment_count: number
|
||||||
|
rrule: string | null
|
||||||
|
// Non-null only when this row is an expanded occurrence of a recurring
|
||||||
|
// series — use it as the stable key for "this specific occurrence" in
|
||||||
|
// edit/delete calls, even though the displayed start_at may differ from
|
||||||
|
// occurrence_start if a per-occurrence override moved it.
|
||||||
|
occurrence_start: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventDepartment {
|
export interface EventDepartment {
|
||||||
|
|
@ -53,6 +59,10 @@ export interface EventDetail extends EventSummary {
|
||||||
color: string
|
color: string
|
||||||
is_system: boolean
|
is_system: boolean
|
||||||
}
|
}
|
||||||
|
// Only meaningful when the detail was fetched with ?occurrence_start= —
|
||||||
|
// otherwise absent/false.
|
||||||
|
is_recurring?: boolean
|
||||||
|
is_exception?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityLogEntry {
|
export interface ActivityLogEntry {
|
||||||
|
|
@ -107,3 +117,12 @@ export interface User {
|
||||||
export function can(user: User, cap: string): boolean {
|
export function can(user: User, cap: string): boolean {
|
||||||
return user.is_admin || user.caps.includes(cap)
|
return user.is_admin || user.caps.includes(cap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flat app config — GET /api/config. Backend may carry additional keys
|
||||||
|
// beyond the ones the UI currently surfaces, hence the index signature.
|
||||||
|
export interface AppConfig {
|
||||||
|
notify_on_assignment: boolean
|
||||||
|
notify_reminders: boolean
|
||||||
|
reminder_minutes_before: number
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue