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
+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 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;
+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;