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:
jtricerolph 2026-07-25 23:04:59 +00:00
parent dad3cc472c
commit ca0dc9b070
28 changed files with 1517 additions and 155 deletions

View file

@ -18,7 +18,10 @@
"ical.js": "^2.1.0", "ical.js": "^2.1.0",
"ics": "^3.8.1", "ics": "^3.8.1",
"jose": "^5.9.6", "jose": "^5.9.6",
"pg": "^8.13.1" "nodemailer": "^9.0.3",
"pg": "^8.13.1",
"rrule": "^2.8.1",
"web-push": "^3.6.7"
} }
}, },
"node_modules/@fastify/accept-negotiator": { "node_modules/@fastify/accept-negotiator": {
@ -215,6 +218,15 @@
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@ -288,6 +300,18 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/atomic-sleep": { "node_modules/atomic-sleep": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@ -325,6 +349,12 @@
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
@ -334,6 +364,12 @@
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -346,26 +382,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/content-disposition/node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@ -398,6 +414,23 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -413,6 +446,15 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@ -605,6 +647,15 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@ -621,6 +672,19 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ical.js": { "node_modules/ical.js": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz", "resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz",
@ -707,6 +771,27 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/light-my-request": { "node_modules/light-my-request": {
"version": "5.14.0", "version": "5.14.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
@ -736,6 +821,12 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.9", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
@ -751,6 +842,15 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": { "node_modules/minipass": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
@ -769,6 +869,12 @@
"obliterator": "^2.0.1" "obliterator": "^2.0.1"
} }
}, },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
@ -787,6 +893,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/nodemailer": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/obliterator": { "node_modules/obliterator": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
@ -1088,12 +1203,41 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/rrule": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
"integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/runes2": { "node_modules/runes2": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz", "resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz",
"integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==", "integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": { "node_modules/safe-regex2": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
@ -1112,6 +1256,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/secure-json-parse": { "node_modules/secure-json-parse": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
@ -1346,6 +1496,12 @@
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-fest": { "node_modules/type-fest": {
"version": "2.19.0", "version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
@ -1358,6 +1514,25 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -17,6 +17,9 @@
"ical.js": "^2.1.0", "ical.js": "^2.1.0",
"ics": "^3.8.1", "ics": "^3.8.1",
"jose": "^5.9.6", "jose": "^5.9.6",
"pg": "^8.13.1" "nodemailer": "^9.0.3",
"pg": "^8.13.1",
"rrule": "^2.8.1",
"web-push": "^3.6.7"
} }
} }

View file

@ -16,6 +16,12 @@ export async function initDb() {
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS events ( CREATE TABLE IF NOT EXISTS events (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
calendar_id INT NOT NULL REFERENCES calendars(id), calendar_id INT NOT NULL REFERENCES calendars(id),
@ -26,6 +32,7 @@ export async function initDb() {
start_at TIMESTAMPTZ NOT NULL, start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL, end_at TIMESTAMPTZ NOT NULL,
all_day BOOLEAN NOT NULL DEFAULT FALSE, all_day BOOLEAN NOT NULL DEFAULT FALSE,
rrule TEXT, -- RFC 5545 RRULE value (no "RRULE:" prefix); non-null = recurring master
sequence INT NOT NULL DEFAULT 0, sequence INT NOT NULL DEFAULT 0,
created_by TEXT, -- email created_by TEXT, -- email
created_by_name TEXT, created_by_name TEXT,
@ -37,6 +44,47 @@ export async function initDb() {
CREATE INDEX IF NOT EXISTS events_start_idx ON events (start_at); CREATE INDEX IF NOT EXISTS events_start_idx ON events (start_at);
CREATE INDEX IF NOT EXISTS events_deleted_idx ON events (deleted_at); CREATE INDEX IF NOT EXISTS events_deleted_idx ON events (deleted_at);
-- Per-occurrence overrides/cancellations for a recurring master event.
-- occurrence_start is the ORIGINAL (un-shifted) RRULE-computed occurrence
-- time RECURRENCE-ID semantics. NULL on an override field = inherit
-- from the master. Deliberately does not cover departments/assignees/
-- attachments every occurrence of a series shows the master's tags.
CREATE TABLE IF NOT EXISTS event_exceptions (
id SERIAL PRIMARY KEY,
master_event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
occurrence_start TIMESTAMPTZ NOT NULL,
is_cancelled BOOLEAN NOT NULL DEFAULT FALSE,
title TEXT,
description TEXT,
location TEXT,
start_at TIMESTAMPTZ,
end_at TIMESTAMPTZ,
all_day BOOLEAN,
updated_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (master_event_id, occurrence_start)
);
-- Dedupes reminder sends across the 5-minute scheduler poll interval.
CREATE TABLE IF NOT EXISTS event_reminders_sent (
event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
occurrence_start TIMESTAMPTZ NOT NULL,
user_email TEXT NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (event_id, occurrence_start, user_email)
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id SERIAL PRIMARY KEY,
user_email TEXT NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS push_subs_email_idx ON push_subscriptions (user_email);
CREATE TABLE IF NOT EXISTS event_departments ( CREATE TABLE IF NOT EXISTS event_departments (
event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE, event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
department_id TEXT NOT NULL, department_id TEXT NOT NULL,
@ -89,6 +137,9 @@ export async function initDb() {
CREATE INDEX IF NOT EXISTS activity_log_entity_idx ON activity_log (entity_type, entity_id); CREATE INDEX IF NOT EXISTS activity_log_entity_idx ON activity_log (entity_type, entity_id);
`) `)
// Incremental migration — idempotent, for deploys that predate recurrence support.
await pool.query(`ALTER TABLE events ADD COLUMN IF NOT EXISTS rrule TEXT`)
await seedDefaults() await seedDefaults()
} }
@ -103,4 +154,21 @@ async function seedDefaults() {
VALUES ('general', 'All Staff', '#c9a84c', FALSE) VALUES ('general', 'All Staff', '#c9a84c', FALSE)
ON CONFLICT (slug) DO NOTHING` ON CONFLICT (slug) DO NOTHING`
) )
const defaults = {
notify_on_assignment: true,
notify_reminders: true,
reminder_minutes_before: 60,
}
for (const [key, value] of Object.entries(defaults)) {
await pool.query(
`INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
[key, JSON.stringify(value)]
)
}
}
export async function getConfig() {
const { rows } = await pool.query('SELECT key, value FROM config')
return Object.fromEntries(rows.map(r => [r.key, r.value]))
} }

View 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
View 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
View 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))
}

View 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 }
}

View 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 }
})
}

View file

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

View file

@ -11,7 +11,8 @@
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0",
"rrule": "^2.8.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
@ -5047,6 +5048,15 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rrule": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
"integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/safe-array-concat": { "node_modules/safe-array-concat": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
@ -5548,6 +5558,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-fest": { "node_modules/type-fest": {
"version": "0.16.0", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",

View file

@ -12,7 +12,8 @@
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0",
"rrule": "^2.8.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.1", "@types/react": "^18.3.1",

View file

@ -1,6 +1,7 @@
import type { import type {
Calendar, EventSummary, EventDetail, EventAttachment, Calendar, EventSummary, EventDetail, EventAttachment,
ActivityLogEntry, Department, AuthUser, CaldavCredential, CaldavCredentialCreated, ActivityLogEntry, Department, AuthUser, CaldavCredential, CaldavCredentialCreated,
AppConfig,
} from './types' } from './types'
const BASE = '/calendar/api' const BASE = '/calendar/api'
@ -53,8 +54,11 @@ export function fetchEvents(filters: EventFilters = {}): Promise<EventSummary[]>
const qs = params.toString() const qs = params.toString()
return request(`/events${qs ? `?${qs}` : ''}`) return request(`/events${qs ? `?${qs}` : ''}`)
} }
export function fetchEvent(id: number): Promise<EventDetail> { // occurrence_start: pass when fetching the effective (merged master+override)
return request(`/events/${id}`) // fields for one occurrence of a recurring series rather than the master row.
export function fetchEvent(id: number, occurrence_start?: string): Promise<EventDetail> {
const qs = occurrence_start ? `?occurrence_start=${encodeURIComponent(occurrence_start)}` : ''
return request(`/events/${id}${qs}`)
} }
export interface EventBody { export interface EventBody {
calendar_id: number calendar_id: number
@ -66,15 +70,32 @@ export interface EventBody {
all_day: boolean all_day: boolean
departments?: { id: string; name: string }[] departments?: { id: string; name: string }[]
assignees?: { email: string; name: string }[] assignees?: { email: string; name: string }[]
// RFC 5545 RRULE property value, e.g. "FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10" —
// bare value, no "RRULE:" prefix and no DTSTART line.
rrule?: string | null
}
export interface EventScopeOptions {
// Present only when scope is 'this' — identifies which occurrence.
occurrence_start?: string
// 'this' edits/deletes just that occurrence (creates/updates a per-occurrence
// override); 'all' (default) edits/deletes the whole series (the master row).
scope?: 'this' | 'all'
} }
export function createEvent(body: EventBody): Promise<EventDetail> { export function createEvent(body: EventBody): Promise<EventDetail> {
return request('/events', { method: 'POST', body: JSON.stringify(body) }) return request('/events', { method: 'POST', body: JSON.stringify(body) })
} }
export function updateEvent(id: number, body: Partial<EventBody>): Promise<EventDetail> { export function updateEvent(id: number, body: Partial<EventBody>, opts: EventScopeOptions = {}): Promise<EventDetail> {
return request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) const payload: Partial<EventBody> & EventScopeOptions = { ...body }
if (opts.occurrence_start) payload.occurrence_start = opts.occurrence_start
if (opts.scope) payload.scope = opts.scope
return request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
} }
export function deleteEvent(id: number): Promise<{ ok: boolean }> { export function deleteEvent(id: number, opts: EventScopeOptions = {}): Promise<{ ok: boolean }> {
return request(`/events/${id}`, { method: 'DELETE' }) const params = new URLSearchParams()
if (opts.occurrence_start) params.set('occurrence_start', opts.occurrence_start)
if (opts.scope) params.set('scope', opts.scope)
const qs = params.toString()
return request(`/events/${id}${qs ? `?${qs}` : ''}`, { method: 'DELETE' })
} }
// Attachments — multipart, so no JSON content-type header // Attachments — multipart, so no JSON content-type header
@ -141,3 +162,22 @@ export async function fetchAssignableUsers(): Promise<AuthUser[]> {
if (!res.ok) throw new Error(`Failed to load users: ${res.status}`) if (!res.ok) throw new Error(`Failed to load users: ${res.status}`)
return res.json() return res.json()
} }
// App config — notification settings, admin-editable
export function fetchConfig(): Promise<AppConfig> {
return request('/config')
}
export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> {
return request(`/config/${key}`, { method: 'PATCH', body: JSON.stringify({ value }) })
}
// Push notifications
export function fetchVapidKey(): Promise<{ publicKey: string }> {
return request('/push/vapid-key')
}
export function subscribePush(subscription: PushSubscriptionJSON): Promise<{ ok: boolean }> {
return request('/push/subscribe', { method: 'POST', body: JSON.stringify(subscription) })
}
export function unsubscribePush(endpoint?: string): Promise<{ ok: boolean }> {
return request('/push/unsubscribe', { method: 'DELETE', body: JSON.stringify(endpoint ? { endpoint } : {}) })
}

View file

@ -1,5 +1,6 @@
import { useEffect, useState, createContext, useContext } from 'react' import { useEffect, useState, createContext, useContext } from 'react'
import type { User } from '../types' import type { User } from '../types'
import { usePushSubscription } from '../hooks/usePushSubscription'
interface AuthCtx { user: User } interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null) const Ctx = createContext<AuthCtx | null>(null)
@ -10,6 +11,11 @@ export function useAuth() {
return ctx return ctx
} }
function PushSubscriber({ user }: { user: User }) {
usePushSubscription(user)
return null
}
export default function AuthGate({ children }: { children: React.ReactNode }) { export default function AuthGate({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
@ -39,6 +45,7 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
return ( return (
<Ctx.Provider value={{ user }}> <Ctx.Provider value={{ user }}>
<PushSubscriber user={user} />
{children} {children}
</Ctx.Provider> </Ctx.Provider>
) )

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { X, Trash2, ChevronDown, ChevronRight, Lock } from 'lucide-react' import { X, Trash2, ChevronDown, ChevronRight, Lock, Repeat } from 'lucide-react'
import type { Calendar, Department, EventAttachment, AuthUser, ActivityLogEntry } from '../types' import type { Calendar, Department, EventAttachment, AuthUser, ActivityLogEntry } from '../types'
import { can } from '../types' import { can } from '../types'
import { useAuth } from './AuthGate' import { useAuth } from './AuthGate'
@ -9,6 +9,7 @@ import {
} from '../api' } from '../api'
import type { EventBody } from '../api' import type { EventBody } from '../api'
import AttachmentList from './AttachmentList' import AttachmentList from './AttachmentList'
import RecurrenceEditor from './RecurrenceEditor'
import { toISODate, toTimeInput } from '../dateUtils' import { toISODate, toTimeInput } from '../dateUtils'
interface Assignee { email: string; name: string } interface Assignee { email: string; name: string }
@ -16,14 +17,23 @@ interface Assignee { email: string; name: string }
interface EventFormProps { interface EventFormProps {
eventId?: number eventId?: number
initialDate?: Date initialDate?: Date
// Set (by the caller, after an OccurrenceScopePrompt choice) when the
// clicked event is an occurrence of a recurring series.
occurrenceStart?: string
scope?: 'this' | 'all'
onClose: () => void onClose: () => void
onSaved: () => void onSaved: () => void
} }
export default function EventForm({ eventId, initialDate, onClose, onSaved }: EventFormProps) { export default function EventForm({ eventId, initialDate, occurrenceStart, scope: scopeProp, onClose, onSaved }: EventFormProps) {
const { user } = useAuth() const { user } = useAuth()
const editing = eventId != null const editing = eventId != null
const permitted = editing ? can(user, 'edit') : can(user, 'create') const permitted = editing ? can(user, 'edit') : can(user, 'create')
const scope: 'this' | 'all' = scopeProp ?? 'all'
// Editing a single occurrence — departments/assignees/attachments/repeat
// rules are series-level only and can't be touched from this path (see
// "deliberate scope limit" in the REST contract).
const editingOccurrence = editing && scope === 'this'
const [loading, setLoading] = useState(editing) const [loading, setLoading] = useState(editing)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -49,6 +59,8 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
const [selectedAssignees, setSelectedAssignees] = useState<Assignee[]>([]) const [selectedAssignees, setSelectedAssignees] = useState<Assignee[]>([])
const [attachments, setAttachments] = useState<EventAttachment[]>([]) const [attachments, setAttachments] = useState<EventAttachment[]>([])
const [isSystem, setIsSystem] = useState(false) const [isSystem, setIsSystem] = useState(false)
const [rrule, setRrule] = useState<string | null>(null)
const [isRecurring, setIsRecurring] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false) const [historyOpen, setHistoryOpen] = useState(false)
const [history, setHistory] = useState<ActivityLogEntry[] | null>(null) const [history, setHistory] = useState<ActivityLogEntry[] | null>(null)
@ -70,7 +82,9 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
useEffect(() => { useEffect(() => {
if (!editing || !eventId) return if (!editing || !eventId) return
setLoading(true) setLoading(true)
fetchEvent(eventId) // For a 'this'-scoped edit, fetch the merged (master + override) fields
// for that one occurrence rather than the master row's own fields.
fetchEvent(eventId, editingOccurrence ? occurrenceStart : undefined)
.then(ev => { .then(ev => {
setCalendarId(ev.calendar_id) setCalendarId(ev.calendar_id)
setTitle(ev.title) setTitle(ev.title)
@ -87,9 +101,15 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
setSelectedAssignees(ev.assignees) setSelectedAssignees(ev.assignees)
setAttachments(ev.attachments) setAttachments(ev.attachments)
setIsSystem(ev.calendar.is_system) setIsSystem(ev.calendar.is_system)
setRrule(ev.rrule ?? null)
setIsRecurring(!!ev.is_recurring)
}) })
.catch(err => setError(err instanceof Error ? err.message : 'Failed to load event')) .catch(err => setError(err instanceof Error ? err.message : 'Failed to load event'))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
// editingOccurrence/occurrenceStart are fixed for the lifetime of a given
// EventForm mount (a fresh instance is mounted per open), so this only
// ever runs once per event.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventId, editing]) }, [eventId, editing])
function toggleDept(dept: Department) { function toggleDept(dept: Department) {
@ -106,30 +126,41 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!calendarId) { setError('Choose a calendar'); return } if (!editingOccurrence && !calendarId) { setError('Choose a calendar'); return }
if (!title.trim()) { setError('Title is required'); return } if (!title.trim()) { setError('Title is required'); return }
const start = allDay ? new Date(`${startDate}T00:00:00`) : new Date(`${startDate}T${startTime}:00`) const start = allDay ? new Date(`${startDate}T00:00:00`) : new Date(`${startDate}T${startTime}:00`)
const end = allDay ? new Date(`${endDate}T23:59:00`) : new Date(`${endDate}T${endTime}:00`) const end = allDay ? new Date(`${endDate}T23:59:00`) : new Date(`${endDate}T${endTime}:00`)
if (end.getTime() < start.getTime()) { setError('End must be after start'); return } if (end.getTime() < start.getTime()) { setError('End must be after start'); return }
const body: EventBody = { const body: Partial<EventBody> = {
calendar_id: calendarId,
title: title.trim(), title: title.trim(),
description: description.trim() || null, description: description.trim() || null,
location: location.trim() || null, location: location.trim() || null,
start_at: start.toISOString(), start_at: start.toISOString(),
end_at: end.toISOString(), end_at: end.toISOString(),
all_day: allDay, all_day: allDay,
departments: selectedDepts, }
assignees: selectedAssignees, // A 'this'-scoped edit never touches calendar/departments/assignees/rrule
// — those are series-level, only changeable via a scope:'all' edit.
if (!editingOccurrence) {
body.calendar_id = calendarId!
body.departments = selectedDepts
body.assignees = selectedAssignees
body.rrule = rrule
} }
setSaving(true) setSaving(true)
setError(null) setError(null)
try { try {
if (editing && eventId) await updateEvent(eventId, body) if (editing && eventId) {
else await createEvent(body) await updateEvent(eventId, body, {
occurrence_start: editingOccurrence ? occurrenceStart : undefined,
scope,
})
} else {
await createEvent(body as EventBody)
}
onSaved() onSaved()
onClose() onClose()
} catch (err) { } catch (err) {
@ -141,11 +172,19 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
async function handleDelete() { async function handleDelete() {
if (!eventId) return if (!eventId) return
if (!confirm('Delete this event? This cannot be undone.')) return const msg = editingOccurrence
? 'Delete this occurrence? Only this date is removed — the rest of the series is unaffected.'
: isRecurring
? 'Delete this event and every occurrence in its series? This cannot be undone.'
: 'Delete this event? This cannot be undone.'
if (!confirm(msg)) return
setSaving(true) setSaving(true)
setError(null) setError(null)
try { try {
await deleteEvent(eventId) await deleteEvent(eventId, {
occurrence_start: editingOccurrence ? occurrenceStart : undefined,
scope,
})
onSaved() onSaved()
onClose() onClose()
} catch (err) { } catch (err) {
@ -192,6 +231,14 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{editingOccurrence && (
<div className="field-hint" style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}>
<Repeat size={13} strokeWidth={1.75} />
Editing this occurrence only calendar, departments, assignees and the repeat rule are
set for the whole series and can only be changed there.
</div>
)}
<div className="field"> <div className="field">
<label>Title</label> <label>Title</label>
<input type="text" value={title} onChange={e => setTitle(e.target.value)} disabled={!permitted} required /> <input type="text" value={title} onChange={e => setTitle(e.target.value)} disabled={!permitted} required />
@ -199,12 +246,16 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
<div className="field"> <div className="field">
<label>Calendar</label> <label>Calendar</label>
{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}> <select value={calendarId ?? ''} onChange={e => setCalendarId(Number(e.target.value))} disabled={!permitted}>
<option value="" disabled>Choose a calendar</option> <option value="" disabled>Choose a calendar</option>
{editableCalendars.map(c => ( {editableCalendars.map(c => (
<option key={c.id} value={c.id}>{c.name}</option> <option key={c.id} value={c.id}>{c.name}</option>
))} ))}
</select> </select>
)}
</div> </div>
<div className="field"> <div className="field">
@ -247,8 +298,18 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
)} )}
</div> </div>
{!editingOccurrence && (
<RecurrenceEditor initialRrule={rrule} onChange={setRrule} disabled={!permitted} />
)}
<div className="field"> <div className="field">
<label>Departments</label> <label>Departments</label>
{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 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{departments.map(dept => ( {departments.map(dept => (
<label key={dept.id} className="field-check"> <label key={dept.id} className="field-check">
@ -263,10 +324,17 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
))} ))}
{departments.length === 0 && <span className="field-hint">No departments configured.</span>} {departments.length === 0 && <span className="field-hint">No departments configured.</span>}
</div> </div>
)}
</div> </div>
<div className="field"> <div className="field">
<label>Assignees</label> <label>Assignees</label>
{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 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{users.map(u => ( {users.map(u => (
<label key={u.email} className="field-check"> <label key={u.email} className="field-check">
@ -281,13 +349,14 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
))} ))}
{users.length === 0 && <span className="field-hint">No staff available.</span>} {users.length === 0 && <span className="field-hint">No staff available.</span>}
</div> </div>
)}
</div> </div>
{editing && eventId && ( {editing && eventId && (
<AttachmentList <AttachmentList
eventId={eventId} eventId={eventId}
attachments={attachments} attachments={attachments}
canEdit={permitted} canEdit={permitted && !editingOccurrence}
onChange={setAttachments} onChange={setAttachments}
/> />
)} )}

View 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>
)
}

View 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>
)
}

View file

@ -9,7 +9,7 @@ export default function AgendaList({ events, onSelectEvent }: {
events: EventSummary[] events: EventSummary[]
date: Date date: Date
onSelectDate?: (d: Date) => void onSelectDate?: (d: Date) => void
onSelectEvent: (id: number) => void onSelectEvent: (ev: EventSummary) => void
}) { }) {
const [q, setQ] = useState('') const [q, setQ] = useState('')
@ -53,7 +53,7 @@ export default function AgendaList({ events, onSelectEvent }: {
<div key={dayKey}> <div key={dayKey}>
<div className="section-title">{formatDayHeader(new Date(dayKey))}</div> <div className="section-title">{formatDayHeader(new Date(dayKey))}</div>
{dayEvents.map(ev => ( {dayEvents.map(ev => (
<div key={ev.id} className="cal-agenda-item" onClick={() => onSelectEvent(ev.id)}> <div key={ev.id} className="cal-agenda-item" onClick={() => onSelectEvent(ev)}>
<div className="cal-agenda-date">{ev.all_day ? 'All day' : formatTime(ev.start_at)}</div> <div className="cal-agenda-date">{ev.all_day ? 'All day' : formatTime(ev.start_at)}</div>
<div className="cal-agenda-main"> <div className="cal-agenda-main">
<div className="cal-agenda-title"> <div className="cal-agenda-title">

View file

@ -5,7 +5,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: {
events: EventSummary[] events: EventSummary[]
date: Date date: Date
onSelectDate?: (d: Date) => void onSelectDate?: (d: Date) => void
onSelectEvent: (id: number) => void onSelectEvent: (ev: EventSummary) => void
}) { }) {
const allDayEvents = events.filter(ev => ev.all_day && eventOccursOnDay(ev, date)) const allDayEvents = events.filter(ev => ev.all_day && eventOccursOnDay(ev, date))
const timedEvents = events.filter(ev => !ev.all_day && eventOccursOnDay(ev, date)) const timedEvents = events.filter(ev => !ev.all_day && eventOccursOnDay(ev, date))
@ -28,7 +28,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: {
key={ev.id} key={ev.id}
className="cal-event-chip" className="cal-event-chip"
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }} style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
onClick={() => onSelectEvent(ev.id)} onClick={() => onSelectEvent(ev)}
> >
{ev.title} {ev.title}
</span> </span>
@ -53,7 +53,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: {
key={ev.id} key={ev.id}
className="cal-week-event" className="cal-week-event"
style={{ top, height, background: ev.calendar_color, color: '#fff' }} style={{ top, height, background: ev.calendar_color, color: '#fff' }}
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }} onClick={e => { e.stopPropagation(); onSelectEvent(ev) }}
title={ev.title} title={ev.title}
> >
{formatTime(ev.start_at)} {ev.title} {formatTime(ev.start_at)} {ev.title}

View file

@ -7,7 +7,7 @@ export default function MonthGrid({ events, date, onSelectDate, onSelectEvent }:
events: EventSummary[] events: EventSummary[]
date: Date date: Date
onSelectDate?: (d: Date) => void onSelectDate?: (d: Date) => void
onSelectEvent: (id: number) => void onSelectEvent: (ev: EventSummary) => void
}) { }) {
const days = monthGridDays(date) const days = monthGridDays(date)
const today = new Date() const today = new Date()
@ -35,7 +35,7 @@ export default function MonthGrid({ events, date, onSelectDate, onSelectEvent }:
className="cal-event-chip" className="cal-event-chip"
style={{ background: ev.calendar_color, color: '#fff' }} style={{ background: ev.calendar_color, color: '#fff' }}
title={ev.title} title={ev.title}
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }} onClick={e => { e.stopPropagation(); onSelectEvent(ev) }}
> >
{!ev.all_day && `${formatTime(ev.start_at)} `}{ev.title} {!ev.all_day && `${formatTime(ev.start_at)} `}{ev.title}
</span> </span>

View file

@ -8,7 +8,7 @@ export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }:
events: EventSummary[] events: EventSummary[]
date: Date date: Date
onSelectDate?: (d: Date) => void onSelectDate?: (d: Date) => void
onSelectEvent: (id: number) => void onSelectEvent: (ev: EventSummary) => void
}) { }) {
const weekStart = startOfWeek(date) const weekStart = startOfWeek(date)
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)) const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
@ -18,6 +18,7 @@ export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }:
const timedByDay = days.map(day => events.filter(ev => !ev.all_day && eventOccursOnDay(ev, day))) const timedByDay = days.map(day => events.filter(ev => !ev.all_day && eventOccursOnDay(ev, day)))
return ( return (
<div className="cal-week-scroll">
<div className="cal-week-grid"> <div className="cal-week-grid">
<div className="cal-week-head-cell" /> <div className="cal-week-head-cell" />
{days.map(day => ( {days.map(day => (
@ -39,7 +40,7 @@ export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }:
key={ev.id} key={ev.id}
className="cal-event-chip" className="cal-event-chip"
style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }} style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }}
onClick={() => onSelectEvent(ev.id)} onClick={() => onSelectEvent(ev)}
> >
{ev.title} {ev.title}
</span> </span>
@ -67,7 +68,7 @@ export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }:
key={ev.id} key={ev.id}
className="cal-week-event" className="cal-week-event"
style={{ top, height, background: ev.calendar_color, color: '#fff' }} style={{ top, height, background: ev.calendar_color, color: '#fff' }}
onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }} onClick={e => { e.stopPropagation(); onSelectEvent(ev) }}
title={ev.title} title={ev.title}
> >
{formatTime(ev.start_at)} {ev.title} {formatTime(ev.start_at)} {ev.title}
@ -77,5 +78,6 @@ export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }:
</div> </div>
))} ))}
</div> </div>
</div>
) )
} }

View 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])
}

View file

@ -121,6 +121,21 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
.top-bar { display: flex; } .top-bar { display: flex; }
.app-shell { flex-direction: column; } .app-shell { flex-direction: column; }
.field-row { flex-direction: column; } .field-row { flex-direction: column; }
/* Calendar page: stack the Calendars filter above the grid instead of
squeezing both into a shared row flex-basis:0 on .cal-main means
flex-wrap alone never triggers here, so force it with flex-direction. */
.cal-layout { flex-direction: column; }
.cal-sidebar-card { width: 100%; }
/* Bigger tap targets for controls staff hit repeatedly on a phone */
.btn-sm { padding: 8px 12px; font-size: 13px; }
.chip { padding: 8px 14px; }
.cal-event-chip { padding: 4px 8px; }
/* Let the week grid keep usable column widths and scroll horizontally
instead of compressing 7 days into an unreadable strip */
.cal-week-scroll .cal-week-grid:not(.cal-day-view) { min-width: 640px; }
} }
.menu-backdrop { .menu-backdrop {
@ -401,6 +416,14 @@ table.data tr.clickable:hover td { background: var(--body-bg); }
Calendar-specific components Calendar-specific components
*/ */
/* ── Calendar page layout ──────────────────────────────────────── */
.cal-layout { display: flex; gap: 16px; align-items: flex-start; }
.cal-sidebar-card { width: 220px; flex-shrink: 0; }
.cal-main { flex: 1; min-width: 0; }
.cal-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.cal-toolbar-label { font-weight: 600; font-size: 14px; flex: 1; min-width: 140px; }
/* ── View switcher (reuses .chip/.chip.active) ────────────────── */ /* ── View switcher (reuses .chip/.chip.active) ────────────────── */
.cal-view-switcher { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } .cal-view-switcher { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
@ -488,6 +511,7 @@ table.data tr.clickable:hover td { background: var(--body-bg); }
.cal-event-more { font-size: 11px; color: var(--text-mid); padding: 1px 6px; cursor: pointer; } .cal-event-more { font-size: 11px; color: var(--text-mid); padding: 1px 6px; cursor: pointer; }
/* ── Week / day grid ───────────────────────────────────────────── */ /* ── Week / day grid ───────────────────────────────────────────── */
.cal-week-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.cal-week-grid { .cal-week-grid {
display: grid; display: grid;
grid-template-columns: 56px repeat(7, 1fr); grid-template-columns: 56px repeat(7, 1fr);

View file

@ -1,7 +1,18 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react' import { Bell, BellOff, Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react'
import type { CaldavCredential, CaldavCredentialCreated } from '../types' import type { CaldavCredential, CaldavCredentialCreated } from '../types'
import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential } from '../api' import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential, fetchVapidKey, subscribePush, unsubscribePush } from '../api'
function urlBase64ToUint8Array(base64: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(b64)
const buf = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i)
return buf.buffer
}
type PushState = 'unsupported' | 'denied' | 'off' | 'on' | 'checking'
export default function CalDavSetup() { export default function CalDavSetup() {
const [credentials, setCredentials] = useState<CaldavCredential[]>([]) const [credentials, setCredentials] = useState<CaldavCredential[]>([])
@ -11,6 +22,74 @@ export default function CalDavSetup() {
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [revealed, setRevealed] = useState<CaldavCredentialCreated | null>(null) const [revealed, setRevealed] = useState<CaldavCredentialCreated | null>(null)
const [pushState, setPushState] = useState<PushState>('checking')
const [pushBusy, setPushBusy] = useState(false)
const [pushError, setPushError] = useState<string | null>(null)
const refreshPushState = useCallback(async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
setPushState('unsupported')
return
}
if (Notification.permission === 'denied') {
setPushState('denied')
return
}
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
setPushState(sub ? 'on' : 'off')
} catch {
setPushState('off')
}
}, [])
useEffect(() => { refreshPushState() }, [refreshPushState])
async function enablePush() {
setPushBusy(true)
setPushError(null)
try {
const reg = await navigator.serviceWorker.ready
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission()
if (permission !== 'granted') { setPushState('denied'); return }
} else if (Notification.permission === 'denied') {
setPushState('denied')
return
}
const { publicKey } = await fetchVapidKey()
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
await subscribePush(sub.toJSON())
setPushState('on')
} catch (err) {
setPushError(err instanceof Error ? err.message : 'Failed to enable push notifications')
} finally {
setPushBusy(false)
}
}
async function disablePush() {
setPushBusy(true)
setPushError(null)
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
if (sub) {
await unsubscribePush(sub.endpoint)
await sub.unsubscribe()
}
setPushState('off')
} catch (err) {
setPushError(err instanceof Error ? err.message : 'Failed to disable push notifications')
} finally {
setPushBusy(false)
}
}
const caldavUrl = `${window.location.origin}/calendar/caldav/` const caldavUrl = `${window.location.origin}/calendar/caldav/`
const reload = useCallback(() => { const reload = useCallback(() => {
@ -72,6 +151,34 @@ export default function CalDavSetup() {
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
<div className="section-title">Push notifications</div>
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{pushState === 'on' ? (
<Bell size={18} strokeWidth={1.75} color="var(--gold)" />
) : (
<BellOff size={18} strokeWidth={1.75} style={{ color: 'var(--text-mid)' }} />
)}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>
{pushState === 'unsupported' && 'Not supported on this device/browser'}
{pushState === 'denied' && 'Blocked — enable notifications for this site in your browser settings'}
{pushState === 'checking' && 'Checking status…'}
{pushState === 'off' && 'Notifications are off on this device'}
{pushState === 'on' && 'Notifications are on for this device'}
</div>
{pushError && <div className="field-hint" style={{ color: 'var(--danger)' }}>{pushError}</div>}
</div>
{(pushState === 'off' || pushState === 'on') && (
<button
className={`btn btn-sm ${pushState === 'off' ? 'btn-primary' : ''}`}
onClick={pushState === 'off' ? enablePush : disablePush}
disabled={pushBusy}
>
{pushBusy ? 'Working…' : pushState === 'off' ? 'Enable' : 'Disable'}
</button>
)}
</div>
<div className="section-title">Your CalDAV devices</div> <div className="section-title">Your CalDAV devices</div>
{loading ? ( {loading ? (
<div className="empty-state">Loading</div> <div className="empty-state">Loading</div>

View file

@ -1,7 +1,9 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { Lock, Pencil, Plus, Trash2, X } from 'lucide-react' import { Lock, Pencil, Plus, Trash2, X } from 'lucide-react'
import type { Calendar } from '../types' import type { Calendar, AppConfig } from '../types'
import { fetchCalendars, createCalendar, updateCalendar, deleteCalendar } from '../api' import { can } from '../types'
import { useAuth } from '../components/AuthGate'
import { fetchCalendars, createCalendar, updateCalendar, deleteCalendar, fetchConfig, updateConfig } from '../api'
// Curated swatch — similarly saturated hues that read well against the navy/gold theme. // Curated swatch — similarly saturated hues that read well against the navy/gold theme.
const SWATCHES = [ const SWATCHES = [
@ -10,10 +12,17 @@ const SWATCHES = [
] ]
export default function CalendarSettings() { export default function CalendarSettings() {
const { user } = useAuth()
const isAdmin = can(user, 'admin')
const [calendars, setCalendars] = useState<Calendar[]>([]) const [calendars, setCalendars] = useState<Calendar[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [config, setConfig] = useState<AppConfig | null>(null)
const [configError, setConfigError] = useState<string | null>(null)
const [configSavingKey, setConfigSavingKey] = useState<string | null>(null)
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null)
const [editName, setEditName] = useState('') const [editName, setEditName] = useState('')
const [editColor, setEditColor] = useState(SWATCHES[0]) const [editColor, setEditColor] = useState(SWATCHES[0])
@ -31,6 +40,27 @@ export default function CalendarSettings() {
useEffect(() => { reload() }, [reload]) useEffect(() => { reload() }, [reload])
useEffect(() => {
if (!isAdmin) return
fetchConfig().then(setConfig).catch(err => setConfigError(err instanceof Error ? err.message : 'Failed to load config'))
}, [isAdmin])
async function saveConfig(key: string, value: unknown) {
if (!config) return
setConfigSavingKey(key)
setConfigError(null)
const previous = config
setConfig({ ...config, [key]: value })
try {
await updateConfig(key, value)
} catch (err) {
setConfig(previous)
setConfigError(err instanceof Error ? err.message : 'Failed to save setting')
} finally {
setConfigSavingKey(null)
}
}
function startEdit(cal: Calendar) { function startEdit(cal: Calendar) {
setEditingId(cal.id) setEditingId(cal.id)
setEditName(cal.name) setEditName(cal.name)
@ -195,6 +225,47 @@ export default function CalendarSettings() {
</table> </table>
</div> </div>
)} )}
{isAdmin && (
<>
<div className="section-title">Notifications</div>
{configError && <div className="error-banner">{configError}</div>}
{!config ? (
<div className="empty-state">Loading</div>
) : (
<div className="card">
<label className="field-check" style={{ marginBottom: 12 }}>
<input
type="checkbox"
checked={!!config.notify_on_assignment}
onChange={e => saveConfig('notify_on_assignment', e.target.checked)}
disabled={configSavingKey !== null}
/>
Notify staff when they're assigned to an event
</label>
<label className="field-check" style={{ marginBottom: 12 }}>
<input
type="checkbox"
checked={!!config.notify_reminders}
onChange={e => saveConfig('notify_reminders', e.target.checked)}
disabled={configSavingKey !== null}
/>
Send reminder notifications before events start
</label>
<div className="field" style={{ maxWidth: 220, marginBottom: 0 }}>
<label>Reminder lead time (minutes)</label>
<input
type="number"
min={1}
value={config.reminder_minutes_before}
onChange={e => saveConfig('reminder_minutes_before', parseInt(e.target.value) || 0)}
disabled={configSavingKey !== null || !config.notify_reminders}
/>
</div>
</div>
)}
</>
)}
</div> </div>
) )
} }

View file

@ -7,6 +7,7 @@ import { fetchCalendars, fetchEvents } from '../api'
import CalendarToggleList from '../components/CalendarToggleList' import CalendarToggleList from '../components/CalendarToggleList'
import ViewSwitcher, { type CalendarViewKey } from '../components/ViewSwitcher' import ViewSwitcher, { type CalendarViewKey } from '../components/ViewSwitcher'
import EventForm from '../components/EventForm' import EventForm from '../components/EventForm'
import OccurrenceScopePrompt from '../components/OccurrenceScopePrompt'
import MonthGrid from '../components/views/MonthGrid' import MonthGrid from '../components/views/MonthGrid'
import WeekGrid from '../components/views/WeekGrid' import WeekGrid from '../components/views/WeekGrid'
import DayGrid from '../components/views/DayGrid' import DayGrid from '../components/views/DayGrid'
@ -55,7 +56,14 @@ export default function CalendarView() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [formState, setFormState] = useState<{ open: boolean; eventId?: number; initialDate?: Date }>({ open: false }) const [formState, setFormState] = useState<{
open: boolean
eventId?: number
initialDate?: Date
occurrenceStart?: string
scope?: 'this' | 'all'
}>({ open: false })
const [scopePromptEvent, setScopePromptEvent] = useState<EventSummary | null>(null)
useEffect(() => { useEffect(() => {
fetchCalendars().then(cals => { fetchCalendars().then(cals => {
@ -91,8 +99,26 @@ export default function CalendarView() {
if (!can(user, 'create')) return if (!can(user, 'create')) return
setFormState({ open: true, initialDate: date }) setFormState({ open: true, initialDate: date })
} }
function openEdit(id: number) { // Recurring occurrences (occurrence_start != null) need the user to choose
setFormState({ open: true, eventId: id }) // "this event" vs "the whole series" before we know what to fetch/save —
// a plain, non-recurring event skips straight to the form as before.
function openEdit(ev: EventSummary) {
if (ev.occurrence_start != null) {
setScopePromptEvent(ev)
} else {
setFormState({ open: true, eventId: ev.id })
}
}
function chooseScope(scope: 'this' | 'all') {
const ev = scopePromptEvent
setScopePromptEvent(null)
if (!ev) return
setFormState({
open: true,
eventId: ev.id,
occurrenceStart: scope === 'this' ? (ev.occurrence_start ?? undefined) : undefined,
scope,
})
} }
function closeForm() { function closeForm() {
setFormState({ open: false }) setFormState({ open: false })
@ -119,14 +145,14 @@ export default function CalendarView() {
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}> <div className="cal-layout">
<div className="card" style={{ width: 220, flexShrink: 0 }}> <div className="card cal-sidebar-card">
<div className="section-title" style={{ marginTop: 0 }}>Calendars</div> <div className="section-title" style={{ marginTop: 0 }}>Calendars</div>
<CalendarToggleList calendars={calendars} visibleIds={visibleIds} onToggle={toggleCalendar} /> <CalendarToggleList calendars={calendars} visibleIds={visibleIds} onToggle={toggleCalendar} />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div className="cal-main">
<div className="filter-row" style={{ marginBottom: 12, alignItems: 'center' }}> <div className="cal-toolbar">
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, -1))}> <button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, -1))}>
<ChevronLeft size={14} strokeWidth={1.75} /> <ChevronLeft size={14} strokeWidth={1.75} />
</button> </button>
@ -134,7 +160,7 @@ export default function CalendarView() {
<button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, 1))}> <button className="btn btn-sm" onClick={() => setCurrentDate(shiftDate(view, currentDate, 1))}>
<ChevronRight size={14} strokeWidth={1.75} /> <ChevronRight size={14} strokeWidth={1.75} />
</button> </button>
<div style={{ fontWeight: 600, fontSize: 14, flex: 1 }}>{labelFor(view, currentDate)}</div> <div className="cal-toolbar-label">{labelFor(view, currentDate)}</div>
<ViewSwitcher view={view} onChange={setView} /> <ViewSwitcher view={view} onChange={setView} />
</div> </div>
@ -156,10 +182,20 @@ export default function CalendarView() {
<EventForm <EventForm
eventId={formState.eventId} eventId={formState.eventId}
initialDate={formState.initialDate} initialDate={formState.initialDate}
occurrenceStart={formState.occurrenceStart}
scope={formState.scope}
onClose={closeForm} onClose={closeForm}
onSaved={reload} onSaved={reload}
/> />
)} )}
{scopePromptEvent && (
<OccurrenceScopePrompt
title={scopePromptEvent.title}
onChoose={chooseScope}
onCancel={() => setScopePromptEvent(null)}
/>
)}
</div> </div>
) )
} }

View file

@ -3,6 +3,7 @@ import { CalendarClock, Users2 } from 'lucide-react'
import type { Department, EventSummary } from '../types' import type { Department, EventSummary } from '../types'
import { fetchMyUpcoming, fetchMyDepartments } from '../api' import { fetchMyUpcoming, fetchMyDepartments } from '../api'
import EventForm from '../components/EventForm' import EventForm from '../components/EventForm'
import OccurrenceScopePrompt from '../components/OccurrenceScopePrompt'
import { formatDayHeader, formatTime } from '../dateUtils' import { formatDayHeader, formatTime } from '../dateUtils'
export default function Dashboard() { export default function Dashboard() {
@ -10,7 +11,12 @@ export default function Dashboard() {
const [departments, setDepartments] = useState<Department[]>([]) const [departments, setDepartments] = useState<Department[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [openEventId, setOpenEventId] = useState<number | null>(null) const [formState, setFormState] = useState<{
eventId: number
occurrenceStart?: string
scope?: 'this' | 'all'
} | null>(null)
const [scopePromptEvent, setScopePromptEvent] = useState<EventSummary | null>(null)
const reload = useCallback(() => { const reload = useCallback(() => {
setLoading(true) setLoading(true)
@ -23,6 +29,24 @@ export default function Dashboard() {
useEffect(() => { reload() }, [reload]) useEffect(() => { reload() }, [reload])
function openEvent(ev: EventSummary) {
if (ev.occurrence_start != null) {
setScopePromptEvent(ev)
} else {
setFormState({ eventId: ev.id })
}
}
function chooseScope(scope: 'this' | 'all') {
const ev = scopePromptEvent
setScopePromptEvent(null)
if (!ev) return
setFormState({
eventId: ev.id,
occurrenceStart: scope === 'this' ? (ev.occurrence_start ?? undefined) : undefined,
scope,
})
}
return ( return (
<div className="page"> <div className="page">
<div className="page-header"> <div className="page-header">
@ -63,7 +87,7 @@ export default function Dashboard() {
<div className="empty-state">Nothing on your calendar in the next 7 days.</div> <div className="empty-state">Nothing on your calendar in the next 7 days.</div>
) : ( ) : (
upcoming.map(ev => ( upcoming.map(ev => (
<div key={ev.id} className="card task-card" onClick={() => setOpenEventId(ev.id)}> <div key={ev.id} className="card task-card" onClick={() => openEvent(ev)}>
<CalendarClock size={16} strokeWidth={1.75} color={ev.calendar_color} style={{ marginTop: 2 }} /> <CalendarClock size={16} strokeWidth={1.75} color={ev.calendar_color} style={{ marginTop: 2 }} />
<div className="task-card-main"> <div className="task-card-main">
<div className="task-card-title">{ev.title}</div> <div className="task-card-title">{ev.title}</div>
@ -77,13 +101,23 @@ export default function Dashboard() {
)) ))
)} )}
{openEventId !== null && ( {formState && (
<EventForm <EventForm
eventId={openEventId} eventId={formState.eventId}
onClose={() => setOpenEventId(null)} occurrenceStart={formState.occurrenceStart}
scope={formState.scope}
onClose={() => setFormState(null)}
onSaved={reload} onSaved={reload}
/> />
)} )}
{scopePromptEvent && (
<OccurrenceScopePrompt
title={scopePromptEvent.title}
onChoose={chooseScope}
onCancel={() => setScopePromptEvent(null)}
/>
)}
</div> </div>
) )
} }

View file

@ -9,3 +9,27 @@ registerRoute(
denylist: [/\/api\//], denylist: [/\/api\//],
}) })
) )
self.addEventListener('push', event => {
const data = event.data?.json() ?? {}
event.waitUntil(
self.registration.showNotification(data.title || 'Calendar', {
body: data.body || '',
icon: '/calendar/icons/icon-192.png',
badge: '/calendar/icons/icon-192.png',
data: { url: data.url || '/calendar/' },
tag: data.tag || 'calendar',
})
)
})
self.addEventListener('notificationclick', event => {
event.notification.close()
const url = event.notification.data?.url || '/calendar/'
event.waitUntil(
clients.matchAll({ type: 'window' }).then(list => {
const existing = list.find(c => c.url.includes('/calendar/') && 'focus' in c)
return existing ? existing.focus() : clients.openWindow(url)
})
)
})

View file

@ -22,6 +22,12 @@ export interface EventSummary {
department_names: string[] department_names: string[]
assignee_names: string[] assignee_names: string[]
attachment_count: number attachment_count: number
rrule: string | null
// Non-null only when this row is an expanded occurrence of a recurring
// series — use it as the stable key for "this specific occurrence" in
// edit/delete calls, even though the displayed start_at may differ from
// occurrence_start if a per-occurrence override moved it.
occurrence_start: string | null
} }
export interface EventDepartment { export interface EventDepartment {
@ -53,6 +59,10 @@ export interface EventDetail extends EventSummary {
color: string color: string
is_system: boolean is_system: boolean
} }
// Only meaningful when the detail was fetched with ?occurrence_start= —
// otherwise absent/false.
is_recurring?: boolean
is_exception?: boolean
} }
export interface ActivityLogEntry { export interface ActivityLogEntry {
@ -107,3 +117,12 @@ export interface User {
export function can(user: User, cap: string): boolean { export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap) return user.is_admin || user.caps.includes(cap)
} }
// Flat app config — GET /api/config. Backend may carry additional keys
// beyond the ones the UI currently surfaces, hence the index signature.
export interface AppConfig {
notify_on_assignment: boolean
notify_reminders: boolean
reminder_minutes_before: number
[key: string]: unknown
}