diff --git a/.env.example b/.env.example index bfbc1a2..3c1bbea 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,13 @@ DISABLE_PUBLIC_SIGNUP=false # DATA_DIR=./data # PORT=3007 + +# Web Push (optional) — notifies parents when a kid checks off a task. +# Requires real HTTPS with a browser-trusted certificate — self-signed does +# NOT work on iOS, and plain HTTP doesn't work at all (see README's +# "Push notifications" section). Leave unset to skip this feature; the +# app degrades gracefully with no broken UI when it's not configured. +# Generate with: npx web-push generate-vapid-keys +# VAPID_PUBLIC_KEY= +# VAPID_PRIVATE_KEY= +# VAPID_SUBJECT=mailto:you@example.com diff --git a/README.md b/README.md index 8e9487c..4011bb7 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,12 @@ off tasks that are already there. - Parents: sign up, invite your spouse, create a calendar per child, edit freely, print, duplicate a calendar for the next week. -- Kids: open their kiosk link on a tablet (add it to the home screen). They - can see the board and check boxes — nothing else. +- Kids: open their kiosk link on a tablet and add it to the home screen — it + launches full-screen with no browser chrome, and they can see the board + and check boxes — nothing else. - Everything a child checks off shows up on the parents' devices within a - few seconds, no reload needed. + few seconds, no reload needed — and, if push notifications are set up, + as an actual notification too (see below). ## Running it @@ -52,6 +54,44 @@ Unraid's usual appdata convention. | `DATA_DIR` | `./data` | Where the SQLite file lives. | | `COOKIE_SECURE` | `false` | Set `true` once this is behind HTTPS (e.g. a reverse proxy with a real certificate), so cookies are marked secure. Leave `false` for plain-HTTP LAN access, or login cookies won't be sent. | | `DISABLE_PUBLIC_SIGNUP` | `false` | Set `true` once your household(s) exist, to stop the `/signup.html` page from creating new ones. Existing invite links still work. | +| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` | unset | Optional — enables push notifications. See "Push notifications" below. Leave all three unset to skip the feature entirely; the app degrades gracefully (no broken UI). | + +## Push notifications + +When a kid checks off a task on the kiosk, parents can get a real push +notification instead of only the silent polling-based update. This is +optional — the app works fully without it. + +**Requires real HTTPS.** Not "it's nice to have," a hard platform +requirement: Web Push needs a secure context, and iOS Safari specifically +requires a browser-trusted certificate (a self-signed one with a +click-through warning does not count). `http://localhost` is a +spec-defined exception for local development, but LAN access via +`http://:3007` will never get push working on iOS. See "Exposing this +beyond your home network" below for your HTTPS options — you need one of +those in place first. + +Setup, once HTTPS is sorted: + +1. Generate a VAPID keypair: `npx web-push generate-vapid-keys` (or + `docker run --rm node:22-alpine npx web-push generate-vapid-keys` if you + don't have Node locally). +2. Set `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, and `VAPID_SUBJECT` + (`mailto:you@example.com` or an `https:` URL) in `.env`, then restart. +3. On the dashboard, each parent clicks "Enable notifications on this + device" — this is per-device, so do it on every phone/computer that + should get notified. + +**On iPhone/iPad specifically**: Safari only allows push notifications for +web apps added to the Home Screen — a regular Safari tab can't subscribe at +all. Tap Share → Add to Home Screen on the dashboard first, then open the +app icon from your Home Screen and enable notifications from there. The +dashboard's notification card explains this in place if it detects it's +needed. + +Regenerating the VAPID keypair invalidates every existing subscription +(everyone would need to re-enable notifications) — treat it as a one-time +setup step, not something to rotate casually. ## How access works @@ -76,3 +116,10 @@ secure). If you want access from outside your home: - Set `SESSION_SECRET` to a real random value (never the dev default). - Consider setting `DISABLE_PUBLIC_SIGNUP=true` once your family's household(s) are created. + +This same HTTPS setup is also the prerequisite for push notifications (see +above) — a real, browser-trusted certificate, not a self-signed one. Options +that satisfy this: a domain + Let's Encrypt via the reverse proxy itself, +a locally-trusted CA like [mkcert](https://github.com/FiloSottile/mkcert) +with its root profile installed on your devices, or a tunnel (Tailscale +Funnel, Cloudflare Tunnel) that terminates real HTTPS for you. diff --git a/UNRAID.md b/UNRAID.md index 2f32ee5..43cf300 100644 --- a/UNRAID.md +++ b/UNRAID.md @@ -52,6 +52,9 @@ backup plugins (e.g. CA Backup/Restore) like any other app's data. - SESSION_SECRET=${SESSION_SECRET:?set a long random value in .env} - COOKIE_SECURE=${COOKIE_SECURE:-false} - DISABLE_PUBLIC_SIGNUP=${DISABLE_PUBLIC_SIGNUP:-false} + - VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-} + - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-} + - VAPID_SUBJECT=${VAPID_SUBJECT:-} restart: unless-stopped ``` @@ -61,6 +64,9 @@ backup plugins (e.g. CA Backup/Restore) like any other app's data. COOKIE_SECURE=false DISABLE_PUBLIC_SIGNUP=false ``` + Leave the three `VAPID_*` vars unset for now unless you're setting up + push notifications and already have real HTTPS in front of this stack — + see "Push notifications" below. 4. **Compose Up**. First run builds the image (a minute or so — it's cloning the repo and running `npm install`), then the container starts on port 3007. @@ -79,6 +85,9 @@ cat > .env <:3007` +won't work for this feature at all, even though the rest of the app is +fine over plain HTTP on your LAN. + +Once HTTPS is confirmed working through your reverse proxy: + +1. Generate a VAPID keypair — from the Unraid terminal: + `docker run --rm node:22-alpine npx web-push generate-vapid-keys`. +2. Set `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` + (`mailto:you@example.com`) in the stack's **ENV** tab (or `.env` for the + terminal path), then rebuild (**Update & Rebuild**, or + `docker compose -f docker-compose.unraid.yml up -d --build`). +3. On the dashboard (loaded through your HTTPS domain, not the bare + `http://:3007`), each parent clicks "Enable notifications on + this device" — per-device, so repeat on every phone/computer. + +On iPhone/iPad, Safari only allows push for web apps added to the Home +Screen — tap Share → Add to Home Screen on the dashboard first, then open +the app icon and enable notifications from there. The dashboard explains +this in place if needed. Regenerating the VAPID keypair invalidates every +existing subscription, so treat it as a one-time setup step. + ## Backups Since data lives at `/mnt/user/appdata/kids-calendar` (a plain file, the diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index ca00653..b2dae39 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -20,4 +20,7 @@ services: - SESSION_SECRET=${SESSION_SECRET:?set a long random value in .env} - COOKIE_SECURE=${COOKIE_SECURE:-false} - DISABLE_PUBLIC_SIGNUP=${DISABLE_PUBLIC_SIGNUP:-false} + - VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-} + - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-} + - VAPID_SUBJECT=${VAPID_SUBJECT:-} restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 4618d4d..a00550f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,9 @@ services: - SESSION_SECRET=${SESSION_SECRET:?set a long random value in .env} - COOKIE_SECURE=${COOKIE_SECURE:-false} - DISABLE_PUBLIC_SIGNUP=${DISABLE_PUBLIC_SIGNUP:-false} + - VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-} + - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-} + - VAPID_SUBJECT=${VAPID_SUBJECT:-} restart: unless-stopped volumes: diff --git a/package-lock.json b/package-lock.json index edf64c1..6a16d6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "bcryptjs": "^2.4.3", "express": "^4.19.2", "express-rate-limit": "^7.4.0", - "express-session": "^1.18.0" + "express-session": "^1.18.0", + "web-push": "^3.6.7" }, "engines": { "node": ">=22.5.0" @@ -30,18 +31,45 @@ "node": ">= 0.6" } }, + "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/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "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/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "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/body-parser": { "version": "1.20.6", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", @@ -66,6 +94,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "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/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -182,6 +216,15 @@ "node": ">= 0.4" } }, + "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/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -444,6 +487,15 @@ "node": ">= 0.4" } }, + "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.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -464,6 +516,42 @@ "url": "https://opencollective.com/express" } }, + "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/https-proxy-agent/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/https-proxy-agent/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/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -491,6 +579,27 @@ "node": ">= 0.10" } }, + "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/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -560,6 +669,21 @@ "node": ">= 0.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -903,6 +1027,25 @@ "engines": { "node": ">= 0.8" } + }, + "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" + } } } } diff --git a/package.json b/package.json index 92eeb8a..5db84da 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "bcryptjs": "^2.4.3", "express": "^4.19.2", "express-rate-limit": "^7.4.0", - "express-session": "^1.18.0" + "express-session": "^1.18.0", + "web-push": "^3.6.7" } } diff --git a/public/calendar.html b/public/calendar.html index 8c6daa0..35d3a7e 100644 --- a/public/calendar.html +++ b/public/calendar.html @@ -7,6 +7,13 @@ + + + + + + + diff --git a/public/dashboard.html b/public/dashboard.html index 304c730..4aa25a0 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -7,6 +7,13 @@ + + + + + + + @@ -21,6 +28,16 @@ +
+

Notifications

+
+

Checking notification support…

+
+ +
+
+
+

Household

@@ -109,6 +126,7 @@
+ diff --git a/public/icons/apple-touch-icon-180.png b/public/icons/apple-touch-icon-180.png new file mode 100644 index 0000000..98f81f1 Binary files /dev/null and b/public/icons/apple-touch-icon-180.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..5c08036 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..fb296eb Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..db14dd8 Binary files /dev/null and b/public/icons/icon-maskable-512.png differ diff --git a/public/js/api.js b/public/js/api.js index f14854a..8be0109 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -25,5 +25,5 @@ const api = { get(path) { return this.request('GET', path); }, post(path, body) { return this.request('POST', path, body === undefined ? {} : body); }, patch(path, body) { return this.request('PATCH', path, body === undefined ? {} : body); }, - del(path) { return this.request('DELETE', path); }, + del(path, body) { return this.request('DELETE', path, body); }, }; diff --git a/public/js/calendarRender.js b/public/js/calendarRender.js index 3a0254a..55079ae 100644 --- a/public/js/calendarRender.js +++ b/public/js/calendarRender.js @@ -49,6 +49,17 @@ function renderCalendar({ calendar, editable, weekdayBoard, weekendWrap, handler row.appendChild(cb); row.appendChild(span); + if (!editable) { + // Kiosk mode: the 24px checkbox alone is a poor touch target, so the + // whole row toggles it. Skip when the tap landed on the checkbox + // itself — it already handles its own toggle+change natively. + row.addEventListener('click', (e) => { + if (e.target === cb) return; + cb.checked = !cb.checked; + cb.dispatchEvent(new Event('change')); + }); + } + if (editable) { span.contentEditable = 'true'; span.addEventListener('keydown', (e) => { diff --git a/public/js/push.js b/public/js/push.js new file mode 100644 index 0000000..3b09a35 --- /dev/null +++ b/public/js/push.js @@ -0,0 +1,103 @@ +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = atob(base64); + return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0))); +} + +function isIOS() { + return /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); +} + +function isStandalone() { + return window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; +} + +const notificationsMessage = document.getElementById('notificationsMessage'); +const notificationsBtn = document.getElementById('notificationsBtn'); + +function showMessage(text) { + notificationsMessage.textContent = text; + notificationsMessage.style.display = 'block'; + notificationsBtn.style.display = 'none'; +} + +function showButton(label, onClick) { + notificationsMessage.style.display = 'none'; + notificationsBtn.textContent = label; + notificationsBtn.style.display = 'inline-block'; + notificationsBtn.onclick = onClick; +} + +let cachedPublicKey = null; + +async function subscribe() { + const reg = await navigator.serviceWorker.ready; + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + return initNotifications(); + } + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(cachedPublicKey), + }); + await api.post('/api/push/subscribe', { subscription: sub.toJSON() }); + initNotifications(); +} + +async function unsubscribe() { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + if (sub) { + const endpoint = sub.endpoint; + await sub.unsubscribe(); + await api.del('/api/push/subscribe', { endpoint }); + } + initNotifications(); +} + +async function initNotifications() { + if (!notificationsMessage) return; + + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + return showMessage('Push notifications aren\'t supported in this browser.'); + } + + if (!window.isSecureContext) { + return showMessage('Notifications require HTTPS. Set up a reverse proxy with a real certificate to use this.'); + } + + let vapid; + try { + vapid = await api.get('/api/push/vapid-public-key'); + } catch { + return showMessage('Could not check notification status.'); + } + if (!vapid.enabled) { + return showMessage('Push notifications aren\'t configured on this server yet.'); + } + cachedPublicKey = vapid.publicKey; + + if (isIOS() && !isStandalone()) { + return showMessage('On iPhone/iPad: tap Share → Add to Home Screen, then open the app icon from your Home Screen and come back here to enable notifications.'); + } + + if (Notification.permission === 'denied') { + return showMessage('Notifications are blocked for this site in your browser settings.'); + } + + const reg = await navigator.serviceWorker.register('/sw.js'); + await navigator.serviceWorker.ready; + const existingSub = await reg.pushManager.getSubscription(); + + if (existingSub) { + showButton('Disable notifications on this device', unsubscribe); + } else { + showButton('Enable notifications on this device', subscribe); + } +} + +if (notificationsMessage) { + initNotifications(); +} diff --git a/public/kiosk.html b/public/kiosk.html index d283f61..d35f1ec 100644 --- a/public/kiosk.html +++ b/public/kiosk.html @@ -3,16 +3,36 @@ My Week - + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..999370d --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "Kids Calendar", + "short_name": "Calendar", + "start_url": "/dashboard.html", + "scope": "/", + "display": "standalone", + "background_color": "#F1F5FB", + "theme_color": "#4A90D9", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..3275582 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,41 @@ +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('push', (event) => { + if (!event.data) return; + const payload = event.data.json(); + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + icon: '/icons/icon-192.png', + badge: '/icons/icon-192.png', + tag: 'kc-task-done', + data: { url: payload.url || '/dashboard.html' }, + }) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const targetUrl = event.notification.data && event.notification.data.url; + if (!targetUrl) return; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { + const existing = clients.find((c) => { + try { + return new URL(c.url).pathname === new URL(targetUrl, self.location.origin).pathname; + } catch { + return false; + } + }); + if (existing) return existing.focus(); + return self.clients.openWindow(targetUrl); + }) + ); +}); diff --git a/scripts/generate-icons.js b/scripts/generate-icons.js new file mode 100644 index 0000000..da463b3 --- /dev/null +++ b/scripts/generate-icons.js @@ -0,0 +1,145 @@ +// One-off dev-time script — generates the app's PWA icon PNGs with zero +// dependencies (no ImageMagick/PIL/canvas available in this environment). +// Run once (`node scripts/generate-icons.js`), commit the output under +// public/icons/ like any other static asset. Not required at runtime. + +const fs = require('node:fs'); +const path = require('node:path'); +const zlib = require('node:zlib'); + +const OUT_DIR = path.join(__dirname, '..', 'public', 'icons'); + +// App's own --school blue (public/css/shared.css) +const BG = [74, 144, 217]; +const WHITE = [255, 255, 255]; + +function distToSegment(px, py, x1, y1, x2, y2) { + const dx = x2 - x1; + const dy = y2 - y1; + const lengthSq = dx * dx + dy * dy; + let t = lengthSq === 0 ? 0 : ((px - x1) * dx + (py - y1) * dy) / lengthSq; + t = Math.max(0, Math.min(1, t)); + const cx = x1 + t * dx; + const cy = y1 + t * dy; + return Math.hypot(px - cx, py - cy); +} + +// Rounded-rect coverage (0..1) for anti-aliased corners; `radius` in px. +function roundedRectCoverage(x, y, w, h, radius) { + const inCoreX = x >= radius && x <= w - radius; + const inCoreY = y >= radius && y <= h - radius; + if (inCoreX || inCoreY) return 1; + + const cx = x < radius ? radius : w - radius; + const cy = y < radius ? radius : h - radius; + const dist = Math.hypot(x - cx, y - cy); + if (dist <= radius - 0.5) return 1; + if (dist >= radius + 0.5) return 0; + return radius + 0.5 - dist; // 1px anti-aliased band +} + +function mix(a, b, t) { + return a + (b - a) * t; +} + +// Checkmark path, proportional to icon size. +function checkmarkCoverage(x, y, size, strokeHalfWidth) { + const p1 = [size * 0.27, size * 0.53]; + const p2 = [size * 0.43, size * 0.68]; + const p3 = [size * 0.74, size * 0.32]; + const d = Math.min( + distToSegment(x, y, p1[0], p1[1], p2[0], p2[1]), + distToSegment(x, y, p2[0], p2[1], p3[0], p3[1]) + ); + if (d <= strokeHalfWidth - 0.5) return 1; + if (d >= strokeHalfWidth + 0.5) return 0; + return strokeHalfWidth + 0.5 - d; +} + +function renderIcon({ size, cornerRadiusRatio, glyphStrokeRatio }) { + const buf = Buffer.alloc(size * size * 4); + const radius = size * cornerRadiusRatio; + const strokeHalfWidth = size * glyphStrokeRatio; + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const bgCoverage = cornerRadiusRatio > 0 ? roundedRectCoverage(x + 0.5, y + 0.5, size, size, radius) : 1; + const glyphCoverage = checkmarkCoverage(x + 0.5, y + 0.5, size, strokeHalfWidth); + + // Composite: background (with its own edge coverage against + // transparent) under the white glyph stroke. + let r = mix(0, BG[0], bgCoverage); + let g = mix(0, BG[1], bgCoverage); + let b = mix(0, BG[2], bgCoverage); + let a = mix(0, 255, bgCoverage); + + r = mix(r, WHITE[0], glyphCoverage); + g = mix(g, WHITE[1], glyphCoverage); + b = mix(b, WHITE[2], glyphCoverage); + a = mix(a, 255, glyphCoverage); + + const idx = (y * size + x) * 4; + buf[idx] = Math.round(r); + buf[idx + 1] = Math.round(g); + buf[idx + 2] = Math.round(b); + buf[idx + 3] = Math.round(a); + } + } + + return buf; +} + +function crc32(buf) { + return zlib.crc32(buf); +} + +function chunk(type, data) { + const typeBuf = Buffer.from(type, 'ascii'); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32BE(data.length, 0); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); + return Buffer.concat([lenBuf, typeBuf, data, crcBuf]); +} + +function encodePng(rgbaBuf, width, height) { + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + + const ihdrData = Buffer.alloc(13); + ihdrData.writeUInt32BE(width, 0); + ihdrData.writeUInt32BE(height, 4); + ihdrData[8] = 8; // bit depth + ihdrData[9] = 6; // color type: truecolor + alpha + ihdrData[10] = 0; // compression + ihdrData[11] = 0; // filter + ihdrData[12] = 0; // interlace + const ihdr = chunk('IHDR', ihdrData); + + const stride = width * 4; + const raw = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y++) { + raw[y * (stride + 1)] = 0; // filter type: None + rgbaBuf.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride); + } + const idat = chunk('IDAT', zlib.deflateSync(raw)); + + const iend = chunk('IEND', Buffer.alloc(0)); + + return Buffer.concat([signature, ihdr, idat, iend]); +} + +function writeIcon(filename, { size, cornerRadiusRatio, glyphStrokeRatio }) { + const rgba = renderIcon({ size, cornerRadiusRatio, glyphStrokeRatio }); + const png = encodePng(rgba, size, size); + fs.mkdirSync(OUT_DIR, { recursive: true }); + fs.writeFileSync(path.join(OUT_DIR, filename), png); + console.log(`wrote ${filename} (${png.length} bytes)`); +} + +writeIcon('icon-192.png', { size: 192, cornerRadiusRatio: 0.18, glyphStrokeRatio: 0.045 }); +writeIcon('icon-512.png', { size: 512, cornerRadiusRatio: 0.18, glyphStrokeRatio: 0.045 }); +writeIcon('apple-touch-icon-180.png', { size: 180, cornerRadiusRatio: 0.18, glyphStrokeRatio: 0.045 }); +// Maskable: background fills edge-to-edge (no rounding — the OS applies its +// own mask), glyph confined within the inner ~80% "safe zone" is handled +// implicitly here since the checkmark path itself is well within that area. +writeIcon('icon-maskable-512.png', { size: 512, cornerRadiusRatio: 0, glyphStrokeRatio: 0.038 }); diff --git a/src/app.js b/src/app.js index 08a8f24..83f6399 100644 --- a/src/app.js +++ b/src/app.js @@ -1,4 +1,5 @@ const path = require('node:path'); +const fs = require('node:fs'); const express = require('express'); const session = require('express-session'); const rateLimit = require('express-rate-limit'); @@ -12,6 +13,8 @@ const { manageRouter: inviteManageRoutes, publicRouter: invitePublicRoutes } = r const childrenRoutes = require('./routes/children'); const calendarRoutes = require('./routes/calendars'); const kioskRoutes = require('./routes/kiosk'); +const pushRoutes = require('./routes/push'); +const { getChildByToken } = require('./middleware/resolveKiosk'); const app = express(); @@ -45,12 +48,32 @@ app.use('/api/household', householdRoutes); app.use('/api/invites', invitePublicRoutes); app.use('/api/children', childrenRoutes); app.use('/api/calendars', calendarRoutes); +app.use('/api/push', pushRoutes); app.use('/api/kiosk/:token', kioskRoutes); -// Short, bookmarkable kiosk URL for the tablet: redirect to the static kiosk page, -// which reads the token back out of the path client-side. +// Short, bookmarkable kiosk URL for the tablet. Templated (not sendFile) so +// each child gets a manifest /apple-touch-icon/title pointing at their +// own token — must be present in the initial HTML, since WebKit's support +// for post-parse-injected manifest links is inconsistent across iOS versions. +const kioskHtmlTemplate = fs.readFileSync(path.join(__dirname, '..', 'public', 'kiosk.html'), 'utf8'); + +function escapeHtmlAttr(str) { + return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +} + app.get('/k/:token', (req, res) => { - res.sendFile(path.join(__dirname, '..', 'public', 'kiosk.html')); + const child = getChildByToken(req.params.token); + const childName = child ? child.name : 'Kids Calendar'; + const title = escapeHtmlAttr(childName); + + const headTags = [ + ``, + ``, + ``, + ].join('\n'); + + const html = kioskHtmlTemplate.replace('', headTags); + res.type('html').send(html); }); // dashboard.js itself bounces to /login.html if the session check fails, diff --git a/src/config.js b/src/config.js index 6b57727..ef4088d 100644 --- a/src/config.js +++ b/src/config.js @@ -9,4 +9,10 @@ module.exports = { sessionSecret: process.env.SESSION_SECRET || 'dev-secret-change-me', cookieSecure: process.env.COOKIE_SECURE === 'true', disablePublicSignup: process.env.DISABLE_PUBLIC_SIGNUP === 'true', + vapidPublicKey: process.env.VAPID_PUBLIC_KEY || null, + vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || null, + vapidSubject: process.env.VAPID_SUBJECT || null, + get pushConfigured() { + return Boolean(this.vapidPublicKey && this.vapidPrivateKey && this.vapidSubject); + }, }; diff --git a/src/db/schema.sql b/src/db/schema.sql index fd2e3fa..e9b6a2b 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -68,6 +68,18 @@ CREATE TABLE IF NOT EXISTS calendar_tasks ( ); CREATE INDEX IF NOT EXISTS idx_tasks_calendar ON calendar_tasks(calendar_id, day_of_week, block_key); +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_id INTEGER NOT NULL REFERENCES parents(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_push_subs_parent ON push_subscriptions(parent_id); + CREATE TABLE IF NOT EXISTS sessions ( sid TEXT PRIMARY KEY, sess TEXT NOT NULL, diff --git a/src/lib/push.js b/src/lib/push.js new file mode 100644 index 0000000..e9abc68 --- /dev/null +++ b/src/lib/push.js @@ -0,0 +1,47 @@ +const webpush = require('web-push'); +const db = require('../db'); +const config = require('../config'); + +if (config.pushConfigured) { + webpush.setVapidDetails(config.vapidSubject, config.vapidPublicKey, config.vapidPrivateKey); +} + +const listSubscriptionsForHouseholdStmt = db.prepare(` + SELECT ps.id, ps.endpoint, ps.p256dh, ps.auth + FROM push_subscriptions ps + JOIN parents p ON p.id = ps.parent_id + WHERE p.household_id = ? +`); +const deleteSubscriptionStmt = db.prepare('DELETE FROM push_subscriptions WHERE id = ?'); + +async function notifyHouseholdParents(householdId, payload) { + if (!config.pushConfigured) return; + + const subs = listSubscriptionsForHouseholdStmt.all(householdId); + if (subs.length === 0) return; + + const body = JSON.stringify(payload); + + const results = await Promise.allSettled( + subs.map((sub) => + webpush + .sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + body, + { TTL: 3600 } + ) + .catch((err) => { + if (err.statusCode === 404 || err.statusCode === 410) { + deleteSubscriptionStmt.run(sub.id); + } else { + console.error('[push] send failed', sub.id, err.statusCode, err.message); + } + throw err; + }) + ) + ); + + return results; +} + +module.exports = { notifyHouseholdParents }; diff --git a/src/middleware/resolveKiosk.js b/src/middleware/resolveKiosk.js index 260661e..f6aa150 100644 --- a/src/middleware/resolveKiosk.js +++ b/src/middleware/resolveKiosk.js @@ -2,14 +2,17 @@ const db = require('../db'); const getChildByTokenStmt = db.prepare('SELECT * FROM children WHERE kiosk_token = ?'); +function getChildByToken(token) { + if (typeof token !== 'string' || !token) return null; + return getChildByTokenStmt.get(token) || null; +} + function resolveKiosk(req, res, next) { - if (typeof req.params.token !== 'string' || !req.params.token) { - return res.status(404).json({ error: 'Invalid kiosk link' }); - } - const child = getChildByTokenStmt.get(req.params.token); + const child = getChildByToken(req.params.token); if (!child) return res.status(404).json({ error: 'Invalid kiosk link' }); req.child = child; next(); } module.exports = resolveKiosk; +module.exports.getChildByToken = getChildByToken; diff --git a/src/routes/kiosk.js b/src/routes/kiosk.js index 52ea40f..ba23a9c 100644 --- a/src/routes/kiosk.js +++ b/src/routes/kiosk.js @@ -3,6 +3,7 @@ const db = require('../db'); const resolveKiosk = require('../middleware/resolveKiosk'); const { hydrateCalendar } = require('../lib/calendarHydrate'); const touchCalendar = require('../lib/touchCalendar'); +const { notifyHouseholdParents } = require('../lib/push'); const router = express.Router({ mergeParams: true }); router.use(resolveKiosk); @@ -16,6 +17,23 @@ const setDoneStmt = db.prepare( // and toggle a task's `done` flag. No create/delete/text-edit/label routes are // wired up here at all — the access boundary is what routes exist, not UI hiding. +router.get('/manifest.webmanifest', (req, res) => { + res.type('application/manifest+json').json({ + name: `${req.child.name}'s Calendar`, + short_name: req.child.name, + start_url: `/k/${req.params.token}`, + scope: `/k/${req.params.token}`, + display: 'standalone', + background_color: '#F1F5FB', + theme_color: '#F1F5FB', + icons: [ + { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' }, + { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' }, + { src: '/icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + }); +}); + router.get('/calendar', (req, res) => { if (!req.child.active_calendar_id) { return res.json({ child: { id: req.child.id, name: req.child.name }, calendar: null }); @@ -39,10 +57,22 @@ router.patch('/tasks/:taskId', (req, res) => { return res.status(400).json({ error: 'done must be a boolean' }); } + const wasDone = !!task.done; + setDoneStmt.run(done ? 1 : 0, task.id); touchCalendar(task.calendar_id); res.json({ task: { id: task.id, done } }); + + // Fire-and-forget: notify parents on the false->true transition only, + // never blocking the kiosk's response on push delivery. + if (done === true && !wasDone) { + notifyHouseholdParents(req.child.household_id, { + title: `${req.child.name} checked something off`, + body: task.text, + url: `/calendar.html?calendarId=${task.calendar_id}`, + }).catch((err) => console.error('[push] notify failed', err)); + } }); module.exports = router; diff --git a/src/routes/push.js b/src/routes/push.js new file mode 100644 index 0000000..aa3eca2 --- /dev/null +++ b/src/routes/push.js @@ -0,0 +1,50 @@ +const express = require('express'); +const db = require('../db'); +const config = require('../config'); +const requireAuth = require('../middleware/requireAuth'); + +const router = express.Router(); +router.use(requireAuth); + +const upsertSubscriptionStmt = db.prepare(` + INSERT INTO push_subscriptions (parent_id, endpoint, p256dh, auth, user_agent, last_seen_at) + VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ON CONFLICT(endpoint) DO UPDATE SET + parent_id = excluded.parent_id, + p256dh = excluded.p256dh, + auth = excluded.auth, + user_agent = excluded.user_agent, + last_seen_at = excluded.last_seen_at +`); +const deleteOwnSubscriptionStmt = db.prepare( + 'DELETE FROM push_subscriptions WHERE endpoint = ? AND parent_id = ?' +); + +router.get('/vapid-public-key', (req, res) => { + if (!config.pushConfigured) return res.json({ enabled: false }); + res.json({ enabled: true, publicKey: config.vapidPublicKey }); +}); + +router.post('/subscribe', (req, res) => { + const { subscription } = req.body || {}; + const endpoint = subscription && subscription.endpoint; + const keys = subscription && subscription.keys; + + if (typeof endpoint !== 'string' || !endpoint || !keys || typeof keys.p256dh !== 'string' || typeof keys.auth !== 'string') { + return res.status(400).json({ error: 'Invalid subscription' }); + } + + upsertSubscriptionStmt.run(req.parent.id, endpoint, keys.p256dh, keys.auth, req.get('user-agent') || null); + res.status(201).json({ subscribed: true }); +}); + +router.delete('/subscribe', (req, res) => { + const { endpoint } = req.body || {}; + if (typeof endpoint !== 'string' || !endpoint) { + return res.status(400).json({ error: 'endpoint is required' }); + } + deleteOwnSubscriptionStmt.run(endpoint, req.parent.id); + res.status(204).end(); +}); + +module.exports = router;