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:
ort
2026-08-15 16:14:56 -04:00
parent 56d8c4616c
commit 2826a3819c
27 changed files with 792 additions and 14 deletions
+10
View File
@@ -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
+50 -3
View File
@@ -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.
+41
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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:
+144 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+18
View File
@@ -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
View File
@@ -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); },
};
+11
View File
@@ -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) => {
+103
View File
@@ -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
View File
@@ -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>
+14
View File
@@ -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" }
]
}
+41
View File
@@ -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);
})
);
});
+145
View File
@@ -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
View File
@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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,
+6
View File
@@ -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);
},
};
+12
View File
@@ -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,
+47
View File
@@ -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 };
+7 -4
View File
@@ -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;
+30
View File
@@ -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;
+50
View File
@@ -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;