From 2826a3819cbf3d878366630b017a6e8611bfefa2 Mon Sep 17 00:00:00 2001 From: ort Date: Sat, 15 Aug 2026 16:14:56 -0400 Subject: [PATCH] Add Web Push notifications, PWA support, and full-screen tablet kiosk Parents get a real push notification when a kid checks off a task (false->true transitions only, fire-and-forget, degrades gracefully with no VAPID keys configured). Dashboard is a fully installable iOS/Android PWA; each child's kiosk link gets its own dynamic per-token manifest so "Add to Home Screen" opens straight into their board in standalone mode. Kiosk view is reworked for tablets: safe-area-aware full-bleed layout, the whole task row is now tappable (previously only the 24px checkbox was, well under Apple's touch-target minimum), and app icons are generated by a small dependency-free PNG encoder (no image tooling available in this environment). Push requires real HTTPS (iOS Safari won't allow it otherwise) - README and UNRAID.md cover VAPID setup and the HTTPS prerequisite. --- .env.example | 10 ++ README.md | 53 +++++++++- UNRAID.md | 41 ++++++++ docker-compose.unraid.yml | 3 + docker-compose.yml | 3 + package-lock.json | 145 +++++++++++++++++++++++++- package.json | 3 +- public/calendar.html | 7 ++ public/dashboard.html | 18 ++++ public/icons/apple-touch-icon-180.png | Bin 0 -> 2711 bytes public/icons/icon-192.png | Bin 0 -> 2934 bytes public/icons/icon-512.png | Bin 0 -> 9807 bytes public/icons/icon-maskable-512.png | Bin 0 -> 6755 bytes public/js/api.js | 2 +- public/js/calendarRender.js | 11 ++ public/js/push.js | 103 ++++++++++++++++++ public/kiosk.html | 22 +++- public/manifest.webmanifest | 14 +++ public/sw.js | 41 ++++++++ scripts/generate-icons.js | 145 ++++++++++++++++++++++++++ src/app.js | 29 +++++- src/config.js | 6 ++ src/db/schema.sql | 12 +++ src/lib/push.js | 47 +++++++++ src/middleware/resolveKiosk.js | 11 +- src/routes/kiosk.js | 30 ++++++ src/routes/push.js | 50 +++++++++ 27 files changed, 792 insertions(+), 14 deletions(-) create mode 100644 public/icons/apple-touch-icon-180.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/icon-maskable-512.png create mode 100644 public/js/push.js create mode 100644 public/manifest.webmanifest create mode 100644 public/sw.js create mode 100644 scripts/generate-icons.js create mode 100644 src/lib/push.js create mode 100644 src/routes/push.js 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 0000000000000000000000000000000000000000..98f81f1da8a7d50458a4e5fd10df8250525c815e GIT binary patch literal 2711 zcmb7G`y04k~ngSBd2UqspdLaEn()iLMD;ej3|*VE+@Ad zhL(~wa|p7;BQ*OTPta#9+s3l$I;IAuW$-C*E`75Q~AS9Ev4ntWARCKX1DpiDG+5=VT%)xkW_yeCcCg>I=sXeWP92VB=PJ)tWSRBQv~=ywOfAm z*Kyjo#+@+c%+OW*_o%>E8(TWG6?E=K8+`p`J#R%Mb~Hul*5|4xErZK8{TrUILvj%H zL4z6kHG`JuOXK`+$Xxl|v)^<0gRyD+ouzuE2Bn8e50}DAtyE(qi*Tmt-|dNp`qZ+p zsXqTUbANmz-Ec&BFh-M|CzGRseq=nu)N}gck4Mp^N5GU09xr*&jF24;RJe@+u;zJTc%G$grs-{ccoG;(f2-bBFL1JTaSJ!XZr+=^vwT{d*@@ z7-zj-)T74CL#0ahWZwE&oBRitkbYf=lkzcYiC==LQasryF({vTi$R}}tW-SFDXj{< zPa0;>ckjY`Eaj2<>^oMv`FBc?M>XCg*00A}*@S$S^N!7_;FYYh68dCRiCU)ntO69B z9H9HHv^o7!W8l!Ldqw1QLZq;Wk@`xPbV5kf9jrc`;I4V^`xOD0hFAq=wy&TGjHW1`8s! zswhRZcT`$8Cwe~+v=+F3U6PGP|1wo%a0-7b+Y9P3MK z-KHNbz3@_LcBNU%PdNp9yTZ1foLRPb0u*>KejGL!EqQG{l?k_;n}t2~K|^(~|GBy} z;C#Uqjn^WXHoZ#3etli;foNR4uvhE@b{O6Vq<3pxRzVqZsj@c{0_Gl~o$XZGXVsfvHn1&9iAZ)ZFn02saeu86v_s zXf7373U`_RlFVa|0R?+G_fOmy-F(7%f7xeMZN2>%Cl0V=epw5-MzUTXO<8-JOaLqy zA0jR2mxpdmzg&BpZd0&V1bx+s z!{JpeCqU{)I6TBL-Aa*zYl!H`SvxN@pNe0lT4h0W2%B6LV*FP#^KPunv0X!TG<`&M z)z{V%=jjQ(+|5{_7v4i;x?X|W<_rf@TVvKxIwymtbraD=7pEw8t=87d_((%FrZ^Ej zxi;S6Bg(ZHHV+T^n>qz(kudJMWjInQ_e~T`(`<8u?zm;6w7GF9Qbkgmgh@A=!{_8} z`!s%f4(Ll1hDuO((`B?XI;19bIJI@A3BL%qa%kb~oChG&(ql9#OFM_W4;i8C1}?I}vo;MFaPyfa0XO&`rY)evl+da&bKT#Sp^HG5R~`$pGrZD}3DMAe!Jiae6COF29Ig{J)A3s>an zC@~i%yyFABpy?!aQU!N4u0f&CJk?vCL+Ojn0Col;N#N+QaaHR{ihN|~8B`3DRJ4o3 z*AuwoyMTBvS{gn*J9jkoQ5Z7`2{SS??$~K)xjK6Z&Y>TxlU2^{Gi+nB#)~PKi#kK% z((|G>&eLLXuODT=qd&oQcw?C?Ump2k`Fh;1oNm&{ziIE@1mbCRn7)sBjD#)w&~lgKb9mgk01Kr3g_6Vfsr6pYoY}%(DZWk4)9)~Q*s~8YsIr%#z;<+a@VA_)Td#G6Fi7;KW1u)pTE)I|{%V*K`|Erqp+uV%9u}}O< zQY~I{-?|MGWFLF?y@C-zzXigpgWMncnaEsGDH zEk;5$-h*z&7ra^ENn}(?Y8EPu%qjqylde5KdJI$3VB2)8X->pFcBk3^W1y=`VB(4$zTr<$z|*8OHgS$b%Y$m?!uB>`RpoEq;rfdikoy zker5SbcQLPMNO>zWBI8{Qb$WM)bt^Pl(Nt3^i#pe(BIhl*pE3ewcx=CFp84}Das3& zEjy`85oL6{Pz9#DAEW)NNc&tO&J-d&SK?UW7}b6hF%QY`j-Z=`zMTG=QromoU9CaZ zqw!VWI%8MUlaqNH>h6tCKi+%oEUJ?i{__D;a&Ql?n_?fzBBLA9o;7CH-B}6-56v|@ z-HpnjNQt;K67`3Kt{<4rj1g6z4v0VFJZ*iuC}JAgdZuABHZVExZeU6vF)%d{B4)V! d**JSksBK`>liNJ}UNCTi98b8|l>)wT{{vj?K{NmW literal 0 HcmV?d00001 diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..5c08036f9a0d7076cb59b926e48097273215cae8 GIT binary patch literal 2934 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 F{6DARoT&f+ literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..fb296eb163ac7b3512afadbc9df906646ea9fd1e GIT binary patch literal 9807 zcmdsd_e0ak*7pP!5kyfz5D?MpUJylCDJldZHdv$zHWnc^K%}YklH%&30s<~5QbLe! zBMJgik|->w5fVZ%^rn(PXhMJx67o#&y7#&7^Spn+`~C!(nKN_d%sHQPW znS;9mO?eGPR0wd4Fx`Z+~K3iAi+Z>;!oP{A;9H~#io6R8zHA=IP} zDqO$l-KF;a*I^&;QEr@}HnOwsHxc@2e?N6}p1e3-K-9@-7nQ$y7{GWy4(lqLpIF3& z7GkL3&cwPEne$8o^e^f?= zPwzVF##7+MR)goZ3>vS|jzQ-s*fUyqcJ_=uU=6Q1^H#4xvGTmbB4Y}@BO(i|*|9#&oqkXK`$c_@n?&v81nQUJ zL;6B)JV!$n-*uER$BTDl2zZT&bWNpwZhqCD>64aIH;v{sue84BXei;Y<$L33_!Jpt z#Kofz!~7^GNb}>k#>Yg#r@OcW$^y8gpsVD(ColdsUDxIF2)UagI~mdQMq_E#R>;p- zjc-Uc{D&rdqNa#!TSHVtJEQA;k-Hk)juvI%ayF0aH~ z`sR+wmY2Rcfos8&U7q%|%#{7ih1A*Tfuz99AdaK;u6W8;@ZOe|m2!Y-`mlHG1!EoB zbn4bNk9Z0KymzDJq}VVkPcKBU<}en*Z!RnPH8F1y15M6*^5bLaAC5W@DgEYq1ya&) z8uFgS(m&D~_f(IS^)Gp^iP96~NTIy*8jp5vAQ0{MYw<`}klrRMGRfwadQK8TMHj`s z)`!vNfO99sQ@1rIs7%-E7I{&#HEf`DCf7h6v>l|qO7UkdbTyYQ-t%_fgn0zAH#;{R zVf)oJA(t*Z_0Y0hp!Hj!H-VzUln~vSoS=%W1*`9_;~tARs57J{_fK=a#P+oz{#Wu zCxInj8ei-q&{`4Mt_Z3MebDRdwD#ThFb9{s?B`!*^X?o%!x33Fu3g5&)eae(_~n+M z&5G7m(1Chij0H|^x^B5MnB3$!vjmTyK~YZ1OQtFHZRfd3?7PDTYrt_=S{J7V_tCF2 zs?dFLO>`udR|`=0^PkzLX%H4k44p+Z@*k1Mz!q8EpxhvF`@-4&z$^{&Mv#=8LVh$z zZPJe#S0%3mNx3O+&&iw>8{OKkLI%q}w+{BdBQ7Df94D5DPra!wpPU)wB(DGs8+4Ox zcLFU-Lfjsl3^>8zc)ehE1i^w+5zM->o$h&EiMikHUi8>;6h$$-A3lFH&i@%2NL zs}4LHDZsT=b5EVZu~o?Bro6&j6)WNv@Pd-U)P3sf0p|9Nj$LQ!$bw)!&k*}M+|2X- zQViAhUGW`@-5~pd?o$^p)-w>z;Y_-($2fyG1^JD|HvGWy_C4ff7!ce9czfG4LP_;I znX?sjR1B8)0RTd(V6BP;4r|`Z998&r}`IK-_0dj$boFGb4*w@h#nrR&PuRA zeE+>u7&Tcw`IafG2!heHX?<1v{zQ%NL%i$Kpf|Oo#&%Gg&{Q$n4g>0GuHq)Zp`?7( zZAH3+z6$Z~LMts}&>Xb<+hX>)hIkX^4L~vS?Iw@&17D6OAg#6ZfD=-;d#@|#!wtxJ zTWM|o@q2C=RHS9VWgX8&t2$ii^KwiW?|>92OEsj_Nlh&y6i50eaM{2!`j*UD(r1H8 zQvpEWyKL3^&ms1Gsio3yTcAMpoO46qFT$wt*YC2Jva+DfktUdxf1@wE$#RR}4ZsU* zT&W(2j>Ve?4&^E;fFfebEO!I`J3L-TfZ61drCo#6g-_k%YcWm$CCYEak%8a*y&DPf z7F$5}lJhXt0|{)syS8f@3q24I@0 zx%lF*2!L16g|V_#1^~l608lZ?0K89v$yp8N=>Hc7IUReqbon4tJp7?v?NFg|VgH_d ze#715SOZasiSYF$1$lsy*%Y$ydPv-Z%;oo%&cwYmFfe#fM%rbTzVPvrGNXLg&VZ=% z9^c5w!Wb>&Ze@Vip6l%{RGGi)divR!-m3$Fm%R);PV_9b?X3T>*G}G2it(0n+7az6U%WYDV1n}vZ{?Y^uHEOSF0;hDz< zG9cR~4>#+G$tOdE8ICwV^ykp)r#d%@duJcPS+5htK3+a=VMoNo{0G^N!ye_*$ApgURi_GNR z_={tb!-kevk?cYB{bGY&V?I6256SVCG)>=P&WheUJ|Av?#R_S|eu*(|XJ`>M2wXze zsRzYt&<2rKP&JU++rN>t7~+Y=68oi$jby-O4;XDpE!($U^Soe~-hO|_Qu%1v7} z?$on8^yvVGs%%9JX=^W>@+VWTU%5}USU$s*16d=3++XLn18~5~-c_B@zd!4dW8*5c zb(J*&ti00y@DFCEdve695F7q)E&Jhle`ukmKQ$bQ`-91~k%;{>wy&$=Q!+(}a#C{$3&7G+lEDp&t%VVvpe zo*EHxT`cpxIVxbZgp*m^9iR5nJQ_2y86IhZ#FPYP2tn54v@)e(c@dp3bJ5XY3l^q0 zBHV#*)+*6VQz7C~Xz-p?!~RP0uIT5>vJLv`7Te5HZPiF%&t5oJ3|;-x$!EWn>YHWQ zrmYj7-bm?_7|LlIx;pgy?x4XZoHP3V6mf>nps&L`bPW?jFTpg&PT${SN`P*Kp?-N_ z@M>JHQDs{tf8MB;m6-*17|AL29=3)Bg3)DK%alo>#NNB=AtkY^PT-i;dE7;Jg&}$AQe&q{j)Q> zv)mJk`$KW}E4&S#<5aziZ^QONe~+lEV<(hqSy^2WT&f0`pZ*D#8`X2KEZ=zLsg~%( zJ?N1(^Fqvl;LWKPY*9cYzsf}mE~#@y%<19i>0|V9XHUX5OL_1{TR7%ulw10mS2-Iv zQ_CU<;FNoYWT|N?g!=qM_oeA<0D=eFGPG4^Qoqht^}8fT5ARilf-9oWUGZg_AxSKe zHr>@VdL;Gvps%ATG{EZc^XENCi@VNH3OKl#FH<3f=5Tzs@LVvKv`C0zy@D-gU`s}i zFdVk5t_t*m%2>#NfqwmJODang5bYPhuTnaM0Q^F&enLMNTSs(TOPToV7{HGaLO*^v z|1H);vSo9Nb2e}wjM$RU#-vZ@r+e;8n&UIda`?jgpX*pzr{zIX*)f*~k|NG1@Wbd( ziiY+dB;+?`u*D4`(MH0%e1>V5!)kPx`VA>?(TAfnlLL*hKwM{Vi-f!jRVWFnfA7k4 zJtH`?_&yVA{&G7&oz#Er9T}{WY7u6Mw2bccf%{9k+ZveRtQUyZfK$@9eO8r-+d%Ov zJ?%=dJ~Q39Gq2ks&NWOA6@C27%4&c~!Zlpt@#gv=Ub}ufks03m7o7d7E|mDbzQM1d_Nt5c9<_q3*Ma#M?`+wRa80Kk}H*je@pB2(pH5|UW9y|2!g~|D zoB+NcxMemu)->*vWSJSi43?-!QwCfzvqtZxgkK$wm6)q*xJ>n(hsoCH@yBZ!Jbn3HE*N>x{37e&LlzgiDwU)w=r=cw?)?Bg%s9_7 zo<12u4Vp|>p-g;%{-sWDN@UMh^=B`7#gLE@(8Xh}b&3|q6Jeh(1l6$!Lg;UG{BOOm zWv{SO1|mWhH0RdL{}BesFbS><``^PL8oJf%{}~1mE&%WJpJ4#c;Fm+hhpG~Th~vYd z+FPMYYfr^zZjdyUB{o8r3cm|Wu8H5H3YH;lzI3LJrP*hP07paq27Ku37llJpA-5{v z8ZDc`W^bPF63$7E&*S0Cnth|w{Ic&|=6ge2q4SuPsyyG}P!n*TUsV?ax2(Gi{Vn-7oahU|1${Axxlu7W%!4IfwU&Chq7ug zFjT!)`|dpX+*MT)qBjim<Zj!YiL^}mtC@@Hrcvi#j zb3=UJnhEP5Xp8j7@SY*bMot$0=9am;jf*wbf|I0GXQgRL;N*|_$lk=^Jn$l`^_GLK zJ{jlrjjMx>2qDc;6VPYOKUL+0bMou!_v6q`XJ+1K4ng-cSXw-#RGWCe;p%K%kOY6! zgxM_7B@cN(IvSNyxZ3tX@mQDkuIHtvYN`qseJbqaEMBw$U_<#&)kttNh+x1Rydd`0x z6_}S1vufHBa}2NkEBIJnEiSi3ls9p)$8Sg_r5WLRPuA=UI-;vclN2OaN*jZDD&An+ z<8q2m2Fqhp)ct;n(`tE?m44LOlOu|a?{xHZDf?cV2zwrrt=&>nu$D(01eiv=o9NRY zqfWr^s>HK5XO7(Z9Zd~z=Bqm)NBom|w8;zW$rDbt@~9O7@(N;>mq0i-l(E-w4SH4N zguwdqWMRp_!nQ44%IT_jlJQmCNzF~GxES({iW`4-y=hDm$rz3M78OhW9{j39`?rG) z4?Gx`U)hMYW+f?&vM1QRDxk9IHcn3g9af|&lU)mQmzqe}Gw{r{y7+bTyi_{v@m_oV zVS|q_pbNEk!NgFcBa@wj6@97Uu-V!=7|bt`gpOAR3IUkeTbZWzI~Zs|e#NPxJu=z9 zx4d&r_tY1cYMnBv($OA<*%H=5u}jz&M^`B0<*Vi^ha-=0Pbo6w&_;X46mv7U9#85s zqf+~el4NDF&nKw0{PxVhRe9}(y%#Vh!;^EkOjOIDI5U<^^fAT&Rju~-epUA-3 zp*^DOm$)Hek{U9#if78EKOW?+xd+#Z@H!eK1}}1b2;`Q~+7M&jod)sci_hPk+I|30 zl#=Roaz5yJxl(cBPOClifP0=zWr_SL<)7guj*FtkVaM}T6OZYMR!HRc%=_pVG>+li z*RM>gcNw~t!YkF`k<2yn_)Zp{qC^$Q_=w)S@ta1KYj{{#)-5NxV1Pzis z=5G``gB@cA2481MZxXZ%$8VCb`>0}(5&r;`41fcLeA}|0n?r z{J)m~*@Y0Sgq(`j*bPsog?3Zjg-5&Pv@rGjq58C)IjOJOmQg69QxW-SvobzDujzLc zJvSwhFyP_i+R$1_dEn9DtRs@~e)HU=mZkXwg?Qgsv9vH1ZCXmI^Y=@SKn_MNQMN=b z93}For8#iKLB#uJcq!eX-Q=z&(hGd((*U=N3l89w+_y-zY_9QKt$t4`>G9{{ET?#k zDao-uz%ShgIY<=gQWMR41r-s{-B08~L|%2nEzY~^!4b=D4X^Zb$U)wULjguZTC3ps zKlv4r9qxC~!}90u4&&+2+W)h>c=SXWc$iGrA7L^CF=!+%Q!IKq#zCi*RsKj11yU_`^!r-?b||Y zQ+tW92;Xb*$((fIV5tk>G~eImeT03W&aSgtPW&Bg`rZVdMQzXpe#aBE2qTwhT@L*3 zSI{qI-gTdq9^CBbCTYmv?FCfm=IldtcH{~r3$I^&cbk?nj1#|C?B6WQZ}w~l!Lj2v z`yWOI=$K0|rgdjX3;KjZBcePkpGD`rojZ-s)CIhU66NRtI+;tq z8~S6Tc!Lkj3>r^+Yzm1GisXfHQYiZl2malW#?V@ZeG}I}=WiIn$k!Y|WPADP2pjzT zkc0Ow%Dcsraz&?lY z_lHVz6$^Ql5)7HNK3k^@Mxl=f+Cg*YD9pRO4m!^2iv_fN;ItQR?}DV-!K{>2I{|`Q zNhm!Td{qhI3U)^L%5!jD^M#K*2=PV^Mtl9JaAccMh-_sDv`f!~?AiSniddp-2q!5G zp|$NM`Rt zGcM2|U_;hNt%n=1iJcXeSouTypK z@LGK7bh%PMd<1+)nunE2$I|y#A*D3Db~eqT}iHSoiDGaKc^Pa;$)crr(Z5FVVgYvXr+cN}*ay30D|i zeSfys>%ylmY67ZK8SHo)TH8wun#fegity9A;5f8FwT0Md=<5`B#a~XKNV!`s4BHZ8 z%Ve#(oeP{y%a!_gJ)$w;6!)jUoI%mgu-iN_9=!>R!H3I(`<}?bS@s`lALDZM6RCSp z<}&!BFc371e3?&%{^DxKpp~+BZwJS-#&5=*pX~~%4+{ufMO8t4b^`AYPQYLhE~*W( zz$HC-(D^3zF{llq*q!+I9~5t})h@!H<0@Axtfo)4tj8{(AB8Xt$iD zrN1j*UHYy)VvE~}UWf3%{mU;?3N`oS6=8~Y!Z*$+24;s2V;O)w27Sn&240s??CoGG zu!%yL8jvMV$OdOgfxwn$-ExYOYa@yUJNGm}n3G%>6**=yU{AIy^m9q6ih^%a&DX8^ z9bW(aKq7a^K_2)W&JED1`QcwISxH&#cW@ibGkucg8m;C?^&oNN+*|ph!SD+Z5s#?4Q8x9{e zW>!2og1`r;PdATMiS-8(b5$u-zUO$AQti0_rI_a*Hi;>oi&|-J@wVM>zRKPnF4+S5boiSP7xETh^2XfFh)yZIF|0SnzV=i4mB8^pAhcFh$_ z)6jaUUd7ud78ZXW${93@6<3{)=D(C)a01zMSbLW(Kh{c~BrkHc~^#A4E|34^!O;e)5 z+}s}=!!mWvk<5_{=U8U5|NfPWrkwrt2glIDet}>g(OL$$t|SebtlV z&L3i)AN!Ew>OTmGJk#jrhjW}82rz2kiT&ss^u}~vun|%fTRfMzGG~cO1{x|qh!Qg3 zjYM@N<|wWNMbwl9k}7`O;#%{)|3 zUna!=%{dK_iTeaknX)TE8yv7^)f+E2|HX-a_it&UNC&8^VUhr9JkPKH62CA)d|f9~ z4U_%7;Uc2re79y;f(3NjQ0MZ%5|FphRuLc!;=Ppu`%?|cE`!8j%%WD%WgbF$xuI2N zKrPUTW zP{l7(?}IlQURe_rzzZMUQkT&A*HGzZW`Fh1|Dqh-lcyX@7d}mPQlOHo!$f7!{4%9o zx77C}Xc@cO)+wFqr~o*pX`|`TpkV<_-;6^zv&-jr9}p5olu%MNEVHFacFjVADN|=9 zcxK=k><$@<@ASKc4H#Iho559I@(Jx=-zFU|3dws-~vPf#t~=K6eC6lr3X78$aZp1Qs+!&dAi zw`Ia!iTulQQ|=7*#&@Qkybm}X(Y=rxgw37#mP``*^ZtQA-Ouy8OP{x=FH`rj(KtSb zQ!jG<3wCch z!D99glC{BRV4R!sh-uoF+c}#}B5okx2F-TPp;qiL`+t%4yAj!Hd5!V(@$`R@_QxQL zpZ_eL&aH}H4}iwW&MBNfyx{=2#^R9#Az>}DYod3r z#rNcm`F14=+b*~0tQpq?D1GN)lU_E4YvPkEUA?sb7^s52h233K(wrwWG{ zYog|n1(Ro z80bbhuQl?dpgjvI6@1$%Sg`2kjFQq{QZTDN!I`Pz=ruBAK29T zNf>sj_>B_1C+v;=w%kAqK)atM(v@~rIn2H%mkFbJu6I)O7Zcsf<=X~WEHmbm-H4*+ zJU>SUpZCm>A>{PzUTsDW?YY2kU$YPUrcXyfJ#raUDOVAkbl#W)V~VHe$l!6#?6x<{ z2qgEe>BfA?-nJ;=!xuBdJ6}lCE5lw*t_FYEGKP3|4vb-5yaQvD*O)+G4L@ZnnRlq3 z>%)i{6R##!9Y-bfwuRv9s+j1pFFSG7{Ua6oqak;lXu%iW>JoSeztq&}M_@ateJBYV zjne~nx%nX@u4!3mBb{kkk49Y6vmTFhrf0nzam~o89O=x+ddoCzZk!P#XasHYZ6#H6 R_@NlEIpT1*_~6;!{|l@XCtd&m literal 0 HcmV?d00001 diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..db14dd80b112539af6ba31675df1856536641d33 GIT binary patch literal 6755 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 literal 0 HcmV?d00001 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;