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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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://<ip>: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.
|
||||
|
||||
@@ -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 <<EOF
|
||||
SESSION_SECRET=$(openssl rand -hex 32)
|
||||
COOKIE_SECURE=false
|
||||
DISABLE_PUBLIC_SIGNUP=false
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=
|
||||
EOF
|
||||
docker compose -f docker-compose.unraid.yml up -d --build
|
||||
```
|
||||
@@ -129,6 +138,38 @@ Proxy Manager, both common on Unraid) with a real HTTPS certificate:
|
||||
`DISABLE_PUBLIC_SIGNUP=true` so `/signup.html` stops accepting new
|
||||
households.
|
||||
|
||||
This same real-HTTPS setup is also the prerequisite for push notifications
|
||||
below — Swag and Nginx Proxy Manager both produce real Let's Encrypt certs
|
||||
given a domain, so if you've already followed this section, you qualify.
|
||||
|
||||
## Push notifications
|
||||
|
||||
Optional: parents get a real push notification (not just the silent
|
||||
polling-based update) when a kid checks off a task on the kiosk.
|
||||
|
||||
**Needs the real HTTPS from the section above already in place** — a
|
||||
self-signed cert doesn't satisfy iOS, and plain `http://<unraid-ip>: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://<unraid-ip>: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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+144
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/shared.css">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180.png">
|
||||
<meta name="theme-color" content="#F1F5FB">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Calendar">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/shared.css">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180.png">
|
||||
<meta name="theme-color" content="#F1F5FB">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Calendar">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -21,6 +28,16 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="section">
|
||||
<h2>Notifications</h2>
|
||||
<div class="entity-card" id="notificationsCard">
|
||||
<p class="meta" id="notificationsMessage">Checking notification support…</p>
|
||||
<div>
|
||||
<button class="tool primary" id="notificationsBtn" style="display:none;">Enable notifications</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Household</h2>
|
||||
<div id="parentsList" class="card-grid"></div>
|
||||
@@ -109,6 +126,7 @@
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/push.js"></script>
|
||||
<script src="/js/dashboard.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
+1
-1
@@ -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); },
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+21
-1
@@ -3,16 +3,36 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>My Week</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/shared.css">
|
||||
<meta name="theme-color" content="#F1F5FB">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<!--KIOSK_HEAD_TAGS-->
|
||||
<style>
|
||||
/* Kiosk is touch-first and has nothing to edit — bigger targets, no toolbar chrome. */
|
||||
header{ justify-content: center; text-align: center; }
|
||||
.toolbar{ display:none; }
|
||||
.board{ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
||||
.task-text{ font-size: 1.02rem; }
|
||||
|
||||
/* Full-bleed standalone launch: respect notches/home-indicator, and stop
|
||||
iOS's rubber-band overscroll from flashing white space at the edges. */
|
||||
body{
|
||||
padding-top: max(24px, env(safe-area-inset-top));
|
||||
padding-right: max(16px, env(safe-area-inset-right));
|
||||
padding-bottom: max(60px, env(safe-area-inset-bottom));
|
||||
padding-left: max(16px, env(safe-area-inset-left));
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* 24px checkbox alone is well under Apple's 44pt touch-target minimum —
|
||||
make the whole row tappable and give the checkbox itself more room. */
|
||||
.task.kiosk{ cursor: pointer; padding: 8px 4px; }
|
||||
.task.kiosk input[type=checkbox]{ width: 32px; height: 32px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
+26
-3
@@ -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 <link>/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, '<').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 = [
|
||||
`<link rel="manifest" href="/api/kiosk/${encodeURIComponent(req.params.token)}/manifest.webmanifest">`,
|
||||
`<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180.png">`,
|
||||
`<meta name="apple-mobile-web-app-title" content="${title}">`,
|
||||
].join('\n');
|
||||
|
||||
const html = kioskHtmlTemplate.replace('<!--KIOSK_HEAD_TAGS-->', headTags);
|
||||
res.type('html').send(html);
|
||||
});
|
||||
|
||||
// dashboard.js itself bounces to /login.html if the session check fails,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -2,14 +2,17 @@ const db = require('../db');
|
||||
|
||||
const getChildByTokenStmt = db.prepare('SELECT * FROM children WHERE kiosk_token = ?');
|
||||
|
||||
function resolveKiosk(req, res, next) {
|
||||
if (typeof req.params.token !== 'string' || !req.params.token) {
|
||||
return res.status(404).json({ error: 'Invalid kiosk link' });
|
||||
function getChildByToken(token) {
|
||||
if (typeof token !== 'string' || !token) return null;
|
||||
return getChildByTokenStmt.get(token) || null;
|
||||
}
|
||||
const child = getChildByTokenStmt.get(req.params.token);
|
||||
|
||||
function resolveKiosk(req, res, next) {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user