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",
|
||||
"ics": "^3.8.1",
|
||||
"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": {
|
||||
|
|
@ -215,6 +218,15 @@
|
|||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
|
|
@ -288,6 +300,18 @@
|
|||
"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": {
|
||||
"version": "1.0.0",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
|
|
@ -334,6 +364,12 @@
|
|||
"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": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
|
|
@ -346,26 +382,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": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
|
|
@ -398,6 +414,23 @@
|
|||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
|
|
@ -413,6 +446,15 @@
|
|||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"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": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
|
|
@ -605,6 +647,15 @@
|
|||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
|
|
@ -621,6 +672,19 @@
|
|||
"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": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz",
|
||||
|
|
@ -707,6 +771,27 @@
|
|||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "5.14.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
|
||||
|
|
@ -736,6 +821,12 @@
|
|||
"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": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
|
|
@ -751,6 +842,15 @@
|
|||
"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": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
|
|
@ -769,6 +869,12 @@
|
|||
"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": {
|
||||
"version": "3.3.16",
|
||||
"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_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": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||
|
|
@ -1088,12 +1203,41 @@
|
|||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz",
|
||||
"integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex2": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
|
||||
|
|
@ -1112,6 +1256,12 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
|
||||
|
|
@ -1346,6 +1496,12 @@
|
|||
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
|
||||
"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": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||
|
|
@ -1358,6 +1514,25 @@
|
|||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
"ical.js": "^2.1.0",
|
||||
"ics": "^3.8.1",
|
||||
"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()
|
||||
);
|
||||
|
||||
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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
calendar_id INT NOT NULL REFERENCES calendars(id),
|
||||
|
|
@ -26,6 +32,7 @@ export async function initDb() {
|
|||
start_at TIMESTAMPTZ NOT NULL,
|
||||
end_at TIMESTAMPTZ NOT NULL,
|
||||
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,
|
||||
created_by TEXT, -- email
|
||||
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_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 (
|
||||
event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
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);
|
||||
`)
|
||||
|
||||
// Incremental migration — idempotent, for deploys that predate recurrence support.
|
||||
await pool.query(`ALTER TABLE events ADD COLUMN IF NOT EXISTS rrule TEXT`)
|
||||
|
||||
await seedDefaults()
|
||||
}
|
||||
|
||||
|
|
@ -103,4 +154,21 @@ async function seedDefaults() {
|
|||
VALUES ('general', 'All Staff', '#c9a84c', FALSE)
|
||||
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",
|
||||
"react": "^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": {
|
||||
"@types/react": "^18.3.1",
|
||||
|
|
@ -5047,6 +5048,15 @@
|
|||
"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": {
|
||||
"version": "1.1.4",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
"lucide-react": "^0.468.0",
|
||||
"react": "^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": {
|
||||
"@types/react": "^18.3.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type {
|
||||
Calendar, EventSummary, EventDetail, EventAttachment,
|
||||
ActivityLogEntry, Department, AuthUser, CaldavCredential, CaldavCredentialCreated,
|
||||
AppConfig,
|
||||
} from './types'
|
||||
|
||||
const BASE = '/calendar/api'
|
||||
|
|
@ -53,8 +54,11 @@ export function fetchEvents(filters: EventFilters = {}): Promise<EventSummary[]>
|
|||
const qs = params.toString()
|
||||
return request(`/events${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
export function fetchEvent(id: number): Promise<EventDetail> {
|
||||
return request(`/events/${id}`)
|
||||
// occurrence_start: pass when fetching the effective (merged master+override)
|
||||
// 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 {
|
||||
calendar_id: number
|
||||
|
|
@ -66,15 +70,32 @@ export interface EventBody {
|
|||
all_day: boolean
|
||||
departments?: { id: 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> {
|
||||
return request('/events', { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
export function updateEvent(id: number, body: Partial<EventBody>): Promise<EventDetail> {
|
||||
return request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||||
export function updateEvent(id: number, body: Partial<EventBody>, opts: EventScopeOptions = {}): Promise<EventDetail> {
|
||||
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 }> {
|
||||
return request(`/events/${id}`, { method: 'DELETE' })
|
||||
export function deleteEvent(id: number, opts: EventScopeOptions = {}): Promise<{ ok: boolean }> {
|
||||
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
|
||||
|
|
@ -141,3 +162,22 @@ export async function fetchAssignableUsers(): Promise<AuthUser[]> {
|
|||
if (!res.ok) throw new Error(`Failed to load users: ${res.status}`)
|
||||
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 type { User } from '../types'
|
||||
import { usePushSubscription } from '../hooks/usePushSubscription'
|
||||
|
||||
interface AuthCtx { user: User }
|
||||
const Ctx = createContext<AuthCtx | null>(null)
|
||||
|
|
@ -10,6 +11,11 @@ export function useAuth() {
|
|||
return ctx
|
||||
}
|
||||
|
||||
function PushSubscriber({ user }: { user: User }) {
|
||||
usePushSubscription(user)
|
||||
return null
|
||||
}
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
|
||||
|
|
@ -39,6 +45,7 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
|||
|
||||
return (
|
||||
<Ctx.Provider value={{ user }}>
|
||||
<PushSubscriber user={user} />
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { can } from '../types'
|
||||
import { useAuth } from './AuthGate'
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from '../api'
|
||||
import type { EventBody } from '../api'
|
||||
import AttachmentList from './AttachmentList'
|
||||
import RecurrenceEditor from './RecurrenceEditor'
|
||||
import { toISODate, toTimeInput } from '../dateUtils'
|
||||
|
||||
interface Assignee { email: string; name: string }
|
||||
|
|
@ -16,14 +17,23 @@ interface Assignee { email: string; name: string }
|
|||
interface EventFormProps {
|
||||
eventId?: number
|
||||
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
|
||||
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 editing = eventId != null
|
||||
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 [saving, setSaving] = useState(false)
|
||||
|
|
@ -49,6 +59,8 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
const [selectedAssignees, setSelectedAssignees] = useState<Assignee[]>([])
|
||||
const [attachments, setAttachments] = useState<EventAttachment[]>([])
|
||||
const [isSystem, setIsSystem] = useState(false)
|
||||
const [rrule, setRrule] = useState<string | null>(null)
|
||||
const [isRecurring, setIsRecurring] = useState(false)
|
||||
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [history, setHistory] = useState<ActivityLogEntry[] | null>(null)
|
||||
|
|
@ -70,7 +82,9 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
useEffect(() => {
|
||||
if (!editing || !eventId) return
|
||||
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 => {
|
||||
setCalendarId(ev.calendar_id)
|
||||
setTitle(ev.title)
|
||||
|
|
@ -87,9 +101,15 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
setSelectedAssignees(ev.assignees)
|
||||
setAttachments(ev.attachments)
|
||||
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'))
|
||||
.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])
|
||||
|
||||
function toggleDept(dept: Department) {
|
||||
|
|
@ -106,30 +126,41 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
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 }
|
||||
|
||||
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`)
|
||||
if (end.getTime() < start.getTime()) { setError('End must be after start'); return }
|
||||
|
||||
const body: EventBody = {
|
||||
calendar_id: calendarId,
|
||||
const body: Partial<EventBody> = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
location: location.trim() || null,
|
||||
start_at: start.toISOString(),
|
||||
end_at: end.toISOString(),
|
||||
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)
|
||||
setError(null)
|
||||
try {
|
||||
if (editing && eventId) await updateEvent(eventId, body)
|
||||
else await createEvent(body)
|
||||
if (editing && eventId) {
|
||||
await updateEvent(eventId, body, {
|
||||
occurrence_start: editingOccurrence ? occurrenceStart : undefined,
|
||||
scope,
|
||||
})
|
||||
} else {
|
||||
await createEvent(body as EventBody)
|
||||
}
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
|
|
@ -141,11 +172,19 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
|
||||
async function handleDelete() {
|
||||
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)
|
||||
setError(null)
|
||||
try {
|
||||
await deleteEvent(eventId)
|
||||
await deleteEvent(eventId, {
|
||||
occurrence_start: editingOccurrence ? occurrenceStart : undefined,
|
||||
scope,
|
||||
})
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
|
|
@ -192,6 +231,14 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<label>Title</label>
|
||||
<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">
|
||||
<label>Calendar</label>
|
||||
<select value={calendarId ?? ''} onChange={e => setCalendarId(Number(e.target.value))} disabled={!permitted}>
|
||||
<option value="" disabled>Choose a calendar…</option>
|
||||
{editableCalendars.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{editingOccurrence ? (
|
||||
<div className="field-hint">{calendars.find(c => c.id === calendarId)?.name ?? '—'}</div>
|
||||
) : (
|
||||
<select value={calendarId ?? ''} onChange={e => setCalendarId(Number(e.target.value))} disabled={!permitted}>
|
||||
<option value="" disabled>Choose a calendar…</option>
|
||||
{editableCalendars.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
|
|
@ -247,47 +298,65 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
|
|||
)}
|
||||
</div>
|
||||
|
||||
{!editingOccurrence && (
|
||||
<RecurrenceEditor initialRrule={rrule} onChange={setRrule} disabled={!permitted} />
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>Departments</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{departments.map(dept => (
|
||||
<label key={dept.id} className="field-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDepts.some(d => d.id === dept.id)}
|
||||
onChange={() => toggleDept(dept)}
|
||||
disabled={!permitted}
|
||||
/>
|
||||
{dept.name}
|
||||
</label>
|
||||
))}
|
||||
{departments.length === 0 && <span className="field-hint">No departments configured.</span>}
|
||||
</div>
|
||||
{editingOccurrence ? (
|
||||
<div className="chip-bar" style={{ margin: 0 }}>
|
||||
{selectedDepts.length === 0 && <span className="field-hint">None</span>}
|
||||
{selectedDepts.map(d => <span key={d.id} className="badge badge-outline">{d.name}</span>)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{departments.map(dept => (
|
||||
<label key={dept.id} className="field-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDepts.some(d => d.id === dept.id)}
|
||||
onChange={() => toggleDept(dept)}
|
||||
disabled={!permitted}
|
||||
/>
|
||||
{dept.name}
|
||||
</label>
|
||||
))}
|
||||
{departments.length === 0 && <span className="field-hint">No departments configured.</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Assignees</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{users.map(u => (
|
||||
<label key={u.email} className="field-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAssignees.some(a => a.email === u.email)}
|
||||
onChange={() => toggleAssignee(u)}
|
||||
disabled={!permitted}
|
||||
/>
|
||||
{u.name}
|
||||
</label>
|
||||
))}
|
||||
{users.length === 0 && <span className="field-hint">No staff available.</span>}
|
||||
</div>
|
||||
{editingOccurrence ? (
|
||||
<div className="chip-bar" style={{ margin: 0 }}>
|
||||
{selectedAssignees.length === 0 && <span className="field-hint">None</span>}
|
||||
{selectedAssignees.map(a => <span key={a.email} className="badge badge-outline">{a.name}</span>)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{users.map(u => (
|
||||
<label key={u.email} className="field-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAssignees.some(a => a.email === u.email)}
|
||||
onChange={() => toggleAssignee(u)}
|
||||
disabled={!permitted}
|
||||
/>
|
||||
{u.name}
|
||||
</label>
|
||||
))}
|
||||
{users.length === 0 && <span className="field-hint">No staff available.</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing && eventId && (
|
||||
<AttachmentList
|
||||
eventId={eventId}
|
||||
attachments={attachments}
|
||||
canEdit={permitted}
|
||||
canEdit={permitted && !editingOccurrence}
|
||||
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[]
|
||||
date: Date
|
||||
onSelectDate?: (d: Date) => void
|
||||
onSelectEvent: (id: number) => void
|
||||
onSelectEvent: (ev: EventSummary) => void
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ export default function AgendaList({ events, onSelectEvent }: {
|
|||
<div key={dayKey}>
|
||||
<div className="section-title">{formatDayHeader(new Date(dayKey))}</div>
|
||||
{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-main">
|
||||
<div className="cal-agenda-title">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: {
|
|||
events: EventSummary[]
|
||||
date: Date
|
||||
onSelectDate?: (d: Date) => void
|
||||
onSelectEvent: (id: number) => void
|
||||
onSelectEvent: (ev: EventSummary) => void
|
||||
}) {
|
||||
const allDayEvents = 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}
|
||||
className="cal-event-chip"
|
||||
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
|
||||
onClick={() => onSelectEvent(ev.id)}
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
>
|
||||
{ev.title}
|
||||
</span>
|
||||
|
|
@ -53,7 +53,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: {
|
|||
key={ev.id}
|
||||
className="cal-week-event"
|
||||
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}
|
||||
>
|
||||
{formatTime(ev.start_at)} {ev.title}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export default function MonthGrid({ events, date, onSelectDate, onSelectEvent }:
|
|||
events: EventSummary[]
|
||||
date: Date
|
||||
onSelectDate?: (d: Date) => void
|
||||
onSelectEvent: (id: number) => void
|
||||
onSelectEvent: (ev: EventSummary) => void
|
||||
}) {
|
||||
const days = monthGridDays(date)
|
||||
const today = new Date()
|
||||
|
|
@ -35,7 +35,7 @@ export default function MonthGrid({ events, date, onSelectDate, onSelectEvent }:
|
|||
className="cal-event-chip"
|
||||
style={{ background: ev.calendar_color, color: '#fff' }}
|
||||
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}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }:
|
|||
events: EventSummary[]
|
||||
date: Date
|
||||
onSelectDate?: (d: Date) => void
|
||||
onSelectEvent: (id: number) => void
|
||||
onSelectEvent: (ev: EventSummary) => void
|
||||
}) {
|
||||
const weekStart = startOfWeek(date)
|
||||
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)))
|
||||
|
||||
return (
|
||||
<div className="cal-week-grid">
|
||||
<div className="cal-week-head-cell" />
|
||||
{days.map(day => (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={`cal-week-head-cell${isSameDay(day, today) ? ' today' : ''}`}
|
||||
onClick={() => onSelectDate?.(day)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{formatDayHeader(day)}
|
||||
</div>
|
||||
))}
|
||||
<div className="cal-week-scroll">
|
||||
<div className="cal-week-grid">
|
||||
<div className="cal-week-head-cell" />
|
||||
{days.map(day => (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={`cal-week-head-cell${isSameDay(day, today) ? ' today' : ''}`}
|
||||
onClick={() => onSelectDate?.(day)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{formatDayHeader(day)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div />
|
||||
{days.map((day, i) => (
|
||||
<div key={`allday-${day.toISOString()}`} style={{ borderLeft: '1px solid var(--card-border)', padding: '3px' }}>
|
||||
{allDayByDay[i].map(ev => (
|
||||
<span
|
||||
key={ev.id}
|
||||
className="cal-event-chip"
|
||||
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
|
||||
onClick={() => onSelectEvent(ev.id)}
|
||||
>
|
||||
{ev.title}
|
||||
</span>
|
||||
<div />
|
||||
{days.map((day, i) => (
|
||||
<div key={`allday-${day.toISOString()}`} style={{ borderLeft: '1px solid var(--card-border)', padding: '3px' }}>
|
||||
{allDayByDay[i].map(ev => (
|
||||
<span
|
||||
key={ev.id}
|
||||
className="cal-event-chip"
|
||||
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
>
|
||||
{ev.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="cal-time-gutter">
|
||||
{HOURS.map(h => (
|
||||
<div key={h} className="cal-time-row">{h === 0 ? '' : `${h}:00`}</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="cal-time-gutter">
|
||||
{HOURS.map(h => (
|
||||
<div key={h} className="cal-time-row">{h === 0 ? '' : `${h}:00`}</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) }}
|
||||
title={ev.title}
|
||||
>
|
||||
{formatTime(ev.start_at)} {ev.title}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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; }
|
||||
.app-shell { 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 {
|
||||
|
|
@ -401,6 +416,14 @@ table.data tr.clickable:hover td { background: var(--body-bg); }
|
|||
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) ────────────────── */
|
||||
.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; }
|
||||
|
||||
/* ── Week / day grid ───────────────────────────────────────────── */
|
||||
.cal-week-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.cal-week-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 56px repeat(7, 1fr);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
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 { 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() {
|
||||
const [credentials, setCredentials] = useState<CaldavCredential[]>([])
|
||||
|
|
@ -11,6 +22,74 @@ export default function CalDavSetup() {
|
|||
const [creating, setCreating] = useState(false)
|
||||
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 reload = useCallback(() => {
|
||||
|
|
@ -72,6 +151,34 @@ export default function CalDavSetup() {
|
|||
|
||||
{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>
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading…</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Lock, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||
import type { Calendar } from '../types'
|
||||
import { fetchCalendars, createCalendar, updateCalendar, deleteCalendar } from '../api'
|
||||
import type { Calendar, AppConfig } from '../types'
|
||||
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.
|
||||
const SWATCHES = [
|
||||
|
|
@ -10,10 +12,17 @@ const SWATCHES = [
|
|||
]
|
||||
|
||||
export default function CalendarSettings() {
|
||||
const { user } = useAuth()
|
||||
const isAdmin = can(user, 'admin')
|
||||
|
||||
const [calendars, setCalendars] = useState<Calendar[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
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 [editName, setEditName] = useState('')
|
||||
const [editColor, setEditColor] = useState(SWATCHES[0])
|
||||
|
|
@ -31,6 +40,27 @@ export default function CalendarSettings() {
|
|||
|
||||
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) {
|
||||
setEditingId(cal.id)
|
||||
setEditName(cal.name)
|
||||
|
|
@ -195,6 +225,47 @@ export default function CalendarSettings() {
|
|||
</table>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { fetchCalendars, fetchEvents } from '../api'
|
|||
import CalendarToggleList from '../components/CalendarToggleList'
|
||||
import ViewSwitcher, { type CalendarViewKey } from '../components/ViewSwitcher'
|
||||
import EventForm from '../components/EventForm'
|
||||
import OccurrenceScopePrompt from '../components/OccurrenceScopePrompt'
|
||||
import MonthGrid from '../components/views/MonthGrid'
|
||||
import WeekGrid from '../components/views/WeekGrid'
|
||||
import DayGrid from '../components/views/DayGrid'
|
||||
|
|
@ -55,7 +56,14 @@ export default function CalendarView() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
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(() => {
|
||||
fetchCalendars().then(cals => {
|
||||
|
|
@ -91,8 +99,26 @@ export default function CalendarView() {
|
|||
if (!can(user, 'create')) return
|
||||
setFormState({ open: true, initialDate: date })
|
||||
}
|
||||
function openEdit(id: number) {
|
||||
setFormState({ open: true, eventId: id })
|
||||
// Recurring occurrences (occurrence_start != null) need the user to choose
|
||||
// "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() {
|
||||
setFormState({ open: false })
|
||||
|
|
@ -119,14 +145,14 @@ export default function CalendarView() {
|
|||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div className="card" style={{ width: 220, flexShrink: 0 }}>
|
||||
<div className="cal-layout">
|
||||
<div className="card cal-sidebar-card">
|
||||
<div className="section-title" style={{ marginTop: 0 }}>Calendars</div>
|
||||
<CalendarToggleList calendars={calendars} visibleIds={visibleIds} onToggle={toggleCalendar} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="filter-row" style={{ marginBottom: 12, alignItems: 'center' }}>
|
||||
<div className="cal-main">
|
||||
<div className="cal-toolbar">
|
||||
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, -1))}>
|
||||
<ChevronLeft size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
|
@ -134,7 +160,7 @@ export default function CalendarView() {
|
|||
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, 1))}>
|
||||
<ChevronRight size={14} strokeWidth={1.75} />
|
||||
</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} />
|
||||
</div>
|
||||
|
||||
|
|
@ -156,10 +182,20 @@ export default function CalendarView() {
|
|||
<EventForm
|
||||
eventId={formState.eventId}
|
||||
initialDate={formState.initialDate}
|
||||
occurrenceStart={formState.occurrenceStart}
|
||||
scope={formState.scope}
|
||||
onClose={closeForm}
|
||||
onSaved={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scopePromptEvent && (
|
||||
<OccurrenceScopePrompt
|
||||
title={scopePromptEvent.title}
|
||||
onChoose={chooseScope}
|
||||
onCancel={() => setScopePromptEvent(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { CalendarClock, Users2 } from 'lucide-react'
|
|||
import type { Department, EventSummary } from '../types'
|
||||
import { fetchMyUpcoming, fetchMyDepartments } from '../api'
|
||||
import EventForm from '../components/EventForm'
|
||||
import OccurrenceScopePrompt from '../components/OccurrenceScopePrompt'
|
||||
import { formatDayHeader, formatTime } from '../dateUtils'
|
||||
|
||||
export default function Dashboard() {
|
||||
|
|
@ -10,7 +11,12 @@ export default function Dashboard() {
|
|||
const [departments, setDepartments] = useState<Department[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
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(() => {
|
||||
setLoading(true)
|
||||
|
|
@ -23,6 +29,24 @@ export default function Dashboard() {
|
|||
|
||||
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 (
|
||||
<div className="page">
|
||||
<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>
|
||||
) : (
|
||||
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 }} />
|
||||
<div className="task-card-main">
|
||||
<div className="task-card-title">{ev.title}</div>
|
||||
|
|
@ -77,13 +101,23 @@ export default function Dashboard() {
|
|||
))
|
||||
)}
|
||||
|
||||
{openEventId !== null && (
|
||||
{formState && (
|
||||
<EventForm
|
||||
eventId={openEventId}
|
||||
onClose={() => setOpenEventId(null)}
|
||||
eventId={formState.eventId}
|
||||
occurrenceStart={formState.occurrenceStart}
|
||||
scope={formState.scope}
|
||||
onClose={() => setFormState(null)}
|
||||
onSaved={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scopePromptEvent && (
|
||||
<OccurrenceScopePrompt
|
||||
title={scopePromptEvent.title}
|
||||
onChoose={chooseScope}
|
||||
onCancel={() => setScopePromptEvent(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,3 +9,27 @@ registerRoute(
|
|||
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[]
|
||||
assignee_names: string[]
|
||||
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 {
|
||||
|
|
@ -53,6 +59,10 @@ export interface EventDetail extends EventSummary {
|
|||
color: string
|
||||
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 {
|
||||
|
|
@ -107,3 +117,12 @@ export interface User {
|
|||
export function can(user: User, cap: string): boolean {
|
||||
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