Parental controls: approvals lift bedtime, bedtime follows the child's clock, urgent push + banner + reminders for requests, reward cap raised to 60
build-container / build (push) Successful in 4m3s
build-container / build (push) Successful in 4m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d0e4e82e0a
commit
0a33119807
11 files changed
+59
-19
No files matched your search
+1
-1
@@ -27,7 +27,7 @@ const alerted = new Map(); // child + kind -> when a parent was last told, so a
|
||||
function alertParents(kid, kind, text) {
|
||||
const key = kid.id + ':' + kind + ':' + text; if (Date.now() - (alerted.get(key) || 0) < 30 * 60e3) return; alerted.set(key, Date.now()); if (alerted.size > 500) alerted.clear();
|
||||
if (kind === 'blocked') log(kid.id, 'blocked', text);
|
||||
for (const a of users.all()) if (!a.disabled && a.role === 'admin') inbox.add(a.id, { kind: 'kid', title: kid.name, body: text, url: kind === 'limit' ? '#/settings/family' : '#/settings', tag: 'kid:' + kid.id + ':' + kind });
|
||||
for (const a of users.all()) if (!a.disabled && a.role === 'admin') { inbox.add(a.id, { kind: 'kid', title: kid.name, body: text, url: kind === 'limit' ? '#/settings/family' : '#/settings', tag: 'kid:' + kid.id + ':' + kind }); if (kind === 'limit') { try { require('./push').notifyUrgent(a.id, { title: kid.name, body: text, url: '#/settings/family', tag: 'kid:' + kid.id + ':limit' }).catch(() => {}); } catch {} } }
|
||||
}
|
||||
|
||||
/** Once a week (Monday morning) every parent gets a short summary of each child's week in their Notifications. */
|
||||
|
||||
@@ -6,7 +6,7 @@ const users = require('./users');
|
||||
const { readBody } = require('./body');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'chores.json');
|
||||
const REPEATS = ['daily', 'weekdays', 'weekly', 'monthly', 'once'], MAX_CHORES = 300, MAX_LOG = 50000;
|
||||
const REPEATS = ['daily', 'weekdays', 'weekly', 'monthly', 'once'], MAX_CHORES = 300, MAX_REWARDS = 60, MAX_LOG = 50000;
|
||||
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
|
||||
@@ -117,7 +117,7 @@ async function handle(req, res) {
|
||||
if (p === '/reward' && req.method === 'POST') { // make, change or remove a reward tier (admin)
|
||||
if (!admin) throw fail(403, 'Only an admin can change the rewards'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), d = db(), old = b.id ? d.rewards.find(x => x.id === b.id) : null;
|
||||
if (b.id && !old) throw fail(404, 'Reward not found'); if (b.remove) { d.rewards = d.rewards.filter(x => x !== old); d.given = d.given.filter(g => g.reward !== b.id); for (const k of Object.keys(d.goals)) for (const w of Object.keys(d.goals[k])) if (d.goals[k][w] === b.id) delete d.goals[k][w]; save(); return json(res, 200, { ok: true }); }
|
||||
if (!old && d.rewards.length >= 12) throw fail(400, 'That is plenty of rewards'); const r = cleanReward(b, old); if (old) d.rewards[d.rewards.indexOf(old)] = r; else d.rewards.push(r); save(); return json(res, 200, r);
|
||||
if (!old && d.rewards.length >= MAX_REWARDS) throw fail(400, 'That is plenty of rewards'); const r = cleanReward(b, old); if (old) d.rewards[d.rewards.indexOf(old)] = r; else d.rewards.push(r); save(); return json(res, 200, r);
|
||||
}
|
||||
if (p === '/give' && req.method === 'POST') { // note that a reward was handed over (admin), or take it back
|
||||
if (!admin) throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), d = db(), wk = monday(ymdOk(b.week) ? b.week : day), i = d.given.findIndex(g => g.week === wk && g.user === String(b.user) && g.reward === String(b.reward));
|
||||
|
||||
+5
-5
@@ -33,7 +33,7 @@ function status(u) {
|
||||
const now = push.localNow(tzOf(u.id)), day = load()[u.id] && load()[u.id][now.day] || { secs: 0, extra: 0 }, base = weekend(now.day) && k.weekendMin ? k.weekendMin : k.limitMin, limit = base ? base + (day.extra || 0) : 0, used = Math.floor((day.secs || 0) / 60);
|
||||
const areas = {}, lockedAreas = []; for (const a of AREAS) { const lim = (k.areaLimits || {})[a]; if (!lim) continue; const um = Math.floor(((day.areas || {})[a] || 0) / 60); areas[a] = { used: um, limit: lim, left: Math.max(0, lim - um) }; if (um >= lim) lockedAreas.push(a); }
|
||||
const q = k.bedtime || {}, asleep = !!q.on && (q.from <= q.to ? now.hm >= q.from && now.hm < q.to : now.hm >= q.from || now.hm < q.to);
|
||||
const locked = asleep ? 'bedtime' : limit && used >= limit ? 'time' : null;
|
||||
const locked = asleep && !Object.values(load()[u.id] || {}).some(x => x && x.bedUntil > Date.now()) ? 'bedtime' : limit && used >= limit ? 'time' : null;
|
||||
return { on: true, used, limit, left: limit ? Math.max(0, limit - used) : null, areas, lockedAreas, extra: day.extra || 0, bedtime: q.on ? { from: q.from, to: q.to } : null, locked, day: now.day };
|
||||
}
|
||||
function ping(u, area) {
|
||||
@@ -47,7 +47,7 @@ function ping(u, area) {
|
||||
/** Minutes per day (newest first) with the split by area, for the parents' view. */
|
||||
function usageByDay(uid, days = 7) { const m = load()[uid] || {}; return Object.keys(m).sort().reverse().slice(0, days).map(day => ({ day, minutes: Math.floor((m[day].secs || 0) / 60), extra: m[day].extra || 0, areas: Object.fromEntries(Object.entries(m[day].areas || {}).map(([a, s]) => [a, Math.floor(s / 60)])) })); }
|
||||
const st0 = u => status(u);
|
||||
function give(uid, minutes) { const u = users.get(uid); if (!u || !u.kid || !u.kid.on) throw fail(400, 'That is not a child account'); const d = load(), now = push.localNow(tzOf(uid)), mine = d[uid] || (d[uid] = {}), day = mine[now.day] || (mine[now.day] = { secs: 0, extra: 0, last: 0 }); day.extra = Math.max(0, Math.min(600, (day.extra || 0) + Math.round(+minutes || 0))); save(d); return status(u); }
|
||||
function give(uid, minutes, liftBedtime) { const u = users.get(uid); if (!u || !u.kid || !u.kid.on) throw fail(400, 'That is not a child account'); const d = load(), now = push.localNow(tzOf(uid)), mine = d[uid] || (d[uid] = {}), day = mine[now.day] || (mine[now.day] = { secs: 0, extra: 0, last: 0 }); const wasBed = liftBedtime && +minutes > 0 && status(u).locked === 'bedtime'; day.extra = Math.max(0, Math.min(600, (day.extra || 0) + Math.round(+minutes || 0))); if (wasBed) day.bedUntil = Date.now() + Math.round(+minutes) * 60e3; /* (a yes at bedtime opens the app for that long) */ save(d); return status(u); }
|
||||
const ALLOWED_WHILE_LOCKED = /^\/api\/(me|kid(\/|$)|csp-report|push\/)/;
|
||||
/** For the server: should this request be refused because the account is locked? Returns the status when it should. */
|
||||
function lockedFor(u, pathname) {
|
||||
@@ -59,14 +59,14 @@ async function handle(req, res, me, hooks = {}) {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/kid'.length) || '/';
|
||||
try {
|
||||
if (p === '/status' && req.method === 'GET') return json(res, 200, status(me));
|
||||
if (p === '/ping' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 2e3) || '{}'); return json(res, 200, ping(me, String(b.area || ''))); }
|
||||
if (p === '/ping' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 2e3) || '{}'); if (b.tz) push.setTz(me.id, String(b.tz)); /* (bedtime follows the child's own clock) */ return json(res, 200, ping(me, String(b.area || ''))); }
|
||||
if (p === '/activity' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 4e3) || '{}'); activity.log(me.id, String(b.kind || ''), b.title); return json(res, 200, { ok: true }); } // (what a child played or read, reported by the app)
|
||||
if (p === '/activity' && req.method === 'GET') { // the parents' view of one child
|
||||
if (me.role !== 'admin') throw fail(403, 'Only an admin can see this'); const t = users.get(u.searchParams.get('user') || ''); if (!t || !activity.isKid(t)) throw fail(404, 'Child not found'); const days = Math.max(1, Math.min(30, +u.searchParams.get('days') || 7));
|
||||
return json(res, 200, { name: t.name, days: usageByDay(t.id, days), recent: activity.recent(t.id, days).slice(0, 200), labels: AREA_LABEL });
|
||||
}
|
||||
if (p === '/ask' && req.method === 'POST') { if (!me.kid || !me.kid.on) throw fail(400, 'This is for child accounts'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').slice(0, 300).trim() || 'Can I have a little more time, please?'; const n = hooks.askParents ? hooks.askParents(me, text) : 0; require('./requests').askTime(me, text, st0(me).locked === 'bedtime' ? 15 : 30); return json(res, 200, { ok: true, told: n }); }
|
||||
if (p === '/extra' && req.method === 'POST') { if (me.role !== 'admin') throw fail(403, 'Only a parent (an admin) can give more time'); const b = JSON.parse(await readBody(req, 1e4) || '{}'); return json(res, 200, give(String(b.user || ''), b.minutes)); }
|
||||
if (p === '/ask' && req.method === 'POST') { if (!me.kid || !me.kid.on) throw fail(400, 'This is for child accounts'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').slice(0, 300).trim() || 'Can I have a little more time, please?'; const n = users.all().filter(x => x.role === 'admin' && !x.disabled && x.id !== me.id).length; /* (parents get one urgent notification from the request itself, not a second one from a chat message) */ require('./requests').askTime(me, text, st0(me).locked === 'bedtime' ? 15 : 30); return json(res, 200, { ok: true, told: n }); }
|
||||
if (p === '/extra' && req.method === 'POST') { if (me.role !== 'admin') throw fail(403, 'Only a parent (an admin) can give more time'); const b = JSON.parse(await readBody(req, 1e4) || '{}'); return json(res, 200, give(String(b.user || ''), b.minutes, true)); }
|
||||
if (p === '/usage' && req.method === 'GET') { if (me.role !== 'admin') throw fail(403, 'Only an admin can see this'); const t = users.get(u.searchParams.get('user') || ''); if (!t) throw fail(404, 'Person not found'); return json(res, 200, status(t)); }
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
|
||||
|
||||
+16
-1
@@ -408,9 +408,24 @@ const kidUi = { st: null, timer: null, told: new Set() };
|
||||
async function kidStart() {
|
||||
if (!ME || !ME.kid || !ME.kid.on || kidUi.timer) return;
|
||||
kidAskButton();
|
||||
const tick = async () => { try { kidUi.st = document.visibilityState === 'visible' ? await jfetch('/api/kid/ping', { method: 'POST', body: JSON.stringify({ area: kidArea() }) }) : await jfetch('/api/kid/status'); } catch (e) { return; } kidPaint(); };
|
||||
const tick = async () => { try { kidUi.st = document.visibilityState === 'visible' ? await jfetch('/api/kid/ping', { method: 'POST', body: JSON.stringify({ area: kidArea(), tz: Intl.DateTimeFormat().resolvedOptions().timeZone }) }) : await jfetch('/api/kid/status'); } catch (e) { return; } kidPaint(); };
|
||||
kidUi.tick = tick; await tick(); kidUi.timer = setInterval(tick, 60e3); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') tick(); });
|
||||
}
|
||||
// ---- A parent: a pill at the top of every screen while a child is asking (with a beep and a title count), so a request is never missed. Tap it to answer.
|
||||
const askWatch = { n: 0, timer: null, tick: null };
|
||||
async function askWatchStart() {
|
||||
if (!ME || ME.role !== 'admin' || askWatch.timer) return; const base = document.title.replace(/^\(\d+\) /, '');
|
||||
const tick = async () => {
|
||||
let r; try { r = await jfetch('/api/kid/requests'); } catch { return; }
|
||||
const open = (r.requests || []).filter(x => x.status === 'open'), who = [...new Set(open.map(x => x.userName))]; let p = document.getElementById('askpill');
|
||||
if (open.length > askWatch.n) { try { toast('🙋 ' + who.join(', ') + (open.some(x => x.time) ? ' is asking for time' : ' is asking')); const a = new (window.AudioContext || window.webkitAudioContext)(), o = a.createOscillator(), g = a.createGain(); o.connect(g); g.connect(a.destination); o.frequency.value = 880; g.gain.setValueAtTime(.15, a.currentTime); g.gain.exponentialRampToValueAtTime(.001, a.currentTime + .5); o.start(); o.stop(a.currentTime + .5); } catch {} }
|
||||
askWatch.n = open.length; document.title = (open.length ? '(' + open.length + ') ' : '') + base;
|
||||
if (!open.length) { if (p) p.remove(); return; }
|
||||
if (!p) { p = document.createElement('button'); p.id = 'askpill'; p.type = 'button'; p.onclick = () => { location.hash = '#/settings/family'; if (askWatch.tick) askWatch.tick(); }; document.body.appendChild(p); }
|
||||
p.textContent = '🙋 ' + (who.length > 2 ? who.length + ' children are' : who.join(' and ') + (who.length > 1 ? ' are' : ' is')) + ' asking' + (open.some(x => x.time) ? ' for time' : '') + (open.length > 1 ? ' (' + open.length + ')' : '');
|
||||
};
|
||||
askWatch.tick = tick; await tick(); askWatch.timer = setInterval(tick, 20e3); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') tick(); });
|
||||
}
|
||||
/** Which part of the app the child is in, so the parents can see where the time goes (and limit one part). */
|
||||
function kidArea() { const r = (location.hash.slice(2).split('/')[0] || 'home'), h = location.hash; return r === 'video' ? 'video' : r === 'music' ? 'music' : /^(audio|podcast|radio|listen|vbv)$/.test(r) || /^#\/store\/(audiobooks|radio)/.test(h) ? 'listen' : /^(read|reading|bible)$/.test(r) || /^#\/store\/books/.test(h) ? 'read' : /^(chat|signal)$/.test(r) ? 'chat' : 'other'; }
|
||||
const actSeen = new Map();
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ for (const ev of ['gesturestart', 'gesturechange', 'gestureend']) document.addEv
|
||||
await loadMe();
|
||||
await loadSettings();
|
||||
if (typeof themeStart === 'function') themeStart();
|
||||
sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof devStart === 'function') devStart(); if (typeof simpleStart === 'function') simpleStart();
|
||||
sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof askWatchStart === 'function') askWatchStart(); if (typeof devStart === 'function') devStart(); if (typeof simpleStart === 'function') simpleStart();
|
||||
addEventListener('hashchange', route);
|
||||
restorePlayer();
|
||||
route();
|
||||
|
||||
@@ -29,7 +29,7 @@ async function famRequests(box) {
|
||||
const open = r.requests.filter(x => x.status === 'open'); if (!open.length) { box.innerHTML = ''; return; }
|
||||
const when = t => new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' });
|
||||
box.innerHTML = `<h2 style="margin-top:0">Asking you (${open.length})</h2><div class="list">${open.map(q => `<div class="row" style="flex-wrap:wrap"><span class="grow" style="min-width:200px"><b>${esc(q.userName)}: ${esc(q.text)}</b><small style="white-space:normal">${when(q.at)}${q.tab ? ' · wants ' + esc((TABS.find(t => t[0] === q.tab) || [0, q.tab])[1]) : ''}${q.minutes ? ' · +' + q.minutes + ' min' : ''}</small></span>${q.time ? [15, 30, 60].map(m => `<button class="btn" data-q="${esc(q.id)}" data-y="1" data-m="${m}">+${m === 60 ? '1 h' : m + ' min'}</button>`).join('') : `<button class="btn" data-q="${esc(q.id)}" data-y="1">Yes</button>`}<button class="btn ghost" data-q="${esc(q.id)}">No</button></div>`).join('')}</div>`;
|
||||
box.querySelectorAll('[data-q]').forEach(b => b.onclick = async () => { const yes = !!b.dataset.y; try { await jfetch('/api/kid/requests/' + b.dataset.q, { method: 'POST', body: JSON.stringify({ approve: yes, minutes: b.dataset.m ? +b.dataset.m : undefined }) }); toast(yes ? 'Said yes' : 'Said no'); famRequests(box); } catch (e) { toast(e.message); } });
|
||||
box.querySelectorAll('[data-q]').forEach(b => b.onclick = async () => { const yes = !!b.dataset.y; try { await jfetch('/api/kid/requests/' + b.dataset.q, { method: 'POST', body: JSON.stringify({ approve: yes, minutes: b.dataset.m ? +b.dataset.m : undefined }) }); toast(yes ? 'Said yes' : 'Said no'); famRequests(box); if (typeof askWatch === 'object' && askWatch.tick) askWatch.tick(); } catch (e) { toast(e.message); } });
|
||||
}
|
||||
// ---- One row per child: their week (minutes by area, what they played, searched and tried), and Plex locking.
|
||||
async function famKids(box) {
|
||||
|
||||
@@ -1211,3 +1211,7 @@ body.qdock #view { padding-right: 340px; }
|
||||
#imgview img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; touch-action: pinch-zoom; -webkit-touch-callout: default; user-select: auto; }
|
||||
#imgview .ivx { position: absolute; top: calc(var(--sat, 0px) + 8px); right: 12px; background: rgba(255, 255, 255, .16); color: #fff; }
|
||||
#imgview .ivsave { position: absolute; top: calc(var(--sat, 0px) + 16px); left: 16px; color: #fff; font-size: 16px; padding: 6px 12px; border-radius: 16px; background: rgba(255, 255, 255, .16); text-decoration: none; cursor: pointer; }
|
||||
#askpill { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 10px); left: 50%; transform: translateX(-50%); z-index: 70; max-width: calc(100vw - 24px); padding: 9px 18px; border-radius: 99px; border: 0; background: var(--danger); color: #fff; font: inherit; font-weight: 700; font-size: 14px; box-shadow: 0 4px 16px rgba(0,0,0,.35); cursor: pointer; animation: askpulse 1.8s ease-in-out infinite; }
|
||||
@keyframes askpulse { 50% { box-shadow: 0 4px 22px rgba(217,69,58,.85); } }
|
||||
@media (prefers-reduced-motion: reduce) { #askpill { animation: none; } }
|
||||
body:has(#sheet) #askpill { display: none; }
|
||||
+4
-2
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v141';
|
||||
const CACHE = 'hub-v143';
|
||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||
@@ -86,7 +86,7 @@ self.addEventListener('fetch', e => {
|
||||
// Push reminders (see push.js on the server)
|
||||
self.addEventListener('push', e => {
|
||||
let d = {}; try { d = e.data.json(); } catch {}
|
||||
e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, data: { url: d.url || '#/home' } }));
|
||||
e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, requireInteraction: !!d.urgent, renotify: !!d.urgent, vibrate: d.urgent ? [200, 100, 200, 100, 200] : undefined, actions: (d.actions || []).slice(0, 2), data: { url: d.url || '#/home', req: d.req } }));
|
||||
});
|
||||
// The push service can replace a device's subscription: make a new one and give it to the server so reminders keep arriving.
|
||||
self.addEventListener('pushsubscriptionchange', e => e.waitUntil((async () => {
|
||||
@@ -98,6 +98,8 @@ self.addEventListener('pushsubscriptionchange', e => e.waitUntil((async () => {
|
||||
})()));
|
||||
self.addEventListener('notificationclick', e => {
|
||||
e.notification.close();
|
||||
const rid = e.notification.data && e.notification.data.req; // "Yes" / "Not now" on a child's request answers it without opening the app
|
||||
if (rid && (e.action === 'yes' || e.action === 'no')) return e.waitUntil(fetch('/api/kid/requests/' + rid, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ approve: e.action === 'yes' }) }).then(r => r.ok ? self.registration.showNotification(e.action === 'yes' ? 'Approved' : 'Declined', { body: 'The child has been told.', icon: '/icons/icon-192.png', tag: 'ans-done', silent: true }) : self.clients.openWindow('/#/settings/family')).catch(() => self.clients.openWindow('/#/settings/family')));
|
||||
const url = '/' + ((e.notification.data && e.notification.data.url) || '');
|
||||
e.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(cs => {
|
||||
const c = cs.find(x => new URL(x.url).origin === self.location.origin);
|
||||
|
||||
@@ -48,7 +48,7 @@ async function send(dir, st, uid, payload) {
|
||||
keys(); const { ref, ...wire } = payload;
|
||||
let changed = false;
|
||||
await Promise.all(st.subs.map(async s => {
|
||||
try { await webpush.sendNotification(s.sub, JSON.stringify(wire), { TTL: 3600 }); stats.sent++; stats.lastOk = Date.now(); }
|
||||
try { await webpush.sendNotification(s.sub, JSON.stringify(wire), payload.urgent ? { TTL: 900, urgency: 'high' } : { TTL: 3600 }); stats.sent++; stats.lastOk = Date.now(); }
|
||||
catch (e) {
|
||||
if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; stats.expired++; }
|
||||
else { stats.failed++; stats.lastErr = String(e.statusCode || e.message).slice(0, 120); stats.lastErrAt = Date.now(); console.warn('[push] send failed:', e.statusCode || e.message); }
|
||||
@@ -191,5 +191,9 @@ async function handle(req, res) {
|
||||
json(res, 404, { error: 'Not found' });
|
||||
}
|
||||
/** A notification for one person's devices (a chat message, say), unless they turned that kind off. */
|
||||
/** Time-sensitive (a child asking for time, a parent's answer): goes to every device at once, ignores the chat on/off switch and quiet hours, stays on screen until seen, and is kept in Notifications too. */
|
||||
async function notifyUrgent(uid, payload) { const dir = users.dirOf(uid), st = load(dir); if (!st.subs.length) return 0; await send(dir, st, null, { ...payload, urgent: true }); return st.subs.length; }
|
||||
/** Remember a person's time zone (the app reports it), so bedtime and reminders follow their own clock. */
|
||||
function setTz(uid, tz) { if (!tz || !validTz(tz)) return; const dir = users.dirOf(uid), st = load(dir); if (st.tz === tz) return; st.tz = tz; try { save(dir, st); } catch {} }
|
||||
async function notify(uid, payload) { const dir = users.dirOf(uid), st = load(dir); if (!st.subs.length || st.chat === false) return; await send(dir, st, null, payload); }
|
||||
module.exports = { handle, start, tick, localNow, notify, health };
|
||||
module.exports = { handle, start, tick, localNow, notify, notifyUrgent, setTz, health };
|
||||
+16
-2
@@ -15,11 +15,17 @@ const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'applicat
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, n);
|
||||
|
||||
/** Phone notification to every parent right away: the buttons answer it without opening the app (Yes with the minutes asked for, or Not now). */
|
||||
function tellParents(r, title) {
|
||||
const mins = r.minutes || 0, actions = r.time || mins ? [{ action: 'yes', title: 'Yes' + (mins ? ', ' + mins + ' min' : '') }, { action: 'no', title: 'Not now' }] : [];
|
||||
for (const a of users.all()) if (!a.disabled && a.role === 'admin') { try { require('./push').notifyUrgent(a.id, { title, body: r.text, url: '#/settings/family', tag: 'req:' + r.id, req: r.id, actions }).catch(() => {}); } catch {} }
|
||||
}
|
||||
/** A request made from the lock screen ("Can I have a little more time?"): one open time request at a time, so asking twice does not pile them up. */
|
||||
function askTime(me, text, minutes) {
|
||||
const old = db().find(r => r.user === me.id && r.status === 'open' && r.time); if (old) return old;
|
||||
const r = { id: crypto.randomBytes(5).toString('hex'), user: me.id, userName: me.name, text: str(text, 200) || 'Can I have a little more time, please?', tab: '', minutes: minutes || 30, time: true, status: 'open', at: Date.now() }; db().push(r); save();
|
||||
for (const a of users.all()) if (!a.disabled && a.role === 'admin') inbox.add(a.id, { kind: 'kid', title: me.name + ' is asking for time', body: r.text, url: '#/settings/family', tag: 'req:' + r.id });
|
||||
tellParents(r, '⏰ ' + me.name + ' is asking for more time');
|
||||
return r;
|
||||
}
|
||||
async function handle(req, res, me) {
|
||||
@@ -33,16 +39,24 @@ async function handle(req, res, me) {
|
||||
const tab = users.TABS.includes(b.tab) && !users.can(me, b.tab) ? b.tab : '', open = db().filter(r => r.user === me.id && r.status === 'open'); if (open.length >= 8) throw fail(400, 'You already have a lot waiting. Ask a parent about those first.');
|
||||
const r = { id: crypto.randomBytes(5).toString('hex'), user: me.id, userName: me.name, text, tab, minutes: Math.max(0, Math.min(120, Math.round(+b.minutes) || 0)), status: 'open', at: Date.now() }; db().push(r); save();
|
||||
for (const a of users.all()) if (!a.disabled && a.role === 'admin') inbox.add(a.id, { kind: 'kid', title: me.name + ' is asking', body: text, url: '#/settings/family', tag: 'req:' + r.id });
|
||||
tellParents(r, me.name + ' is asking' + (r.minutes ? ' for ' + r.minutes + ' more min' : ''));
|
||||
return json(res, 200, r);
|
||||
}
|
||||
let m; if ((m = /^\/([0-9a-f]{10})$/.exec(p)) && req.method === 'POST') { // a parent answers
|
||||
if (!admin) throw fail(403, 'Only a parent (an admin) can answer'); const r = db().find(x => x.id === m[1]); if (!r) throw fail(404, 'Request not found'); if (r.status !== 'open') throw fail(400, 'That one is already answered');
|
||||
const b = JSON.parse(await readBody(req, 4e3) || '{}'), yes = !!b.approve, kid = users.get(r.user); r.status = yes ? 'approved' : 'denied'; r.note = str(b.note, 200); r.by = me.name; r.decidedAt = Date.now();
|
||||
if (yes && kid) { if (r.tab && !users.can(kid, r.tab)) users.update(kid.id, { tabs: [...new Set([...(kid.tabs || []), r.tab])] }); const mins = b.minutes !== undefined ? Math.max(0, Math.min(240, Math.round(+b.minutes) || 0)) : r.minutes; if (mins) { r.minutes = mins; try { require('./kidlimits').give(kid.id, mins); } catch {} } }
|
||||
if (yes && kid) { if (r.tab && !users.can(kid, r.tab)) users.update(kid.id, { tabs: [...new Set([...(kid.tabs || []), r.tab])] }); const mins = b.minutes !== undefined ? Math.max(0, Math.min(240, Math.round(+b.minutes) || 0)) : r.minutes; if (mins) { r.minutes = mins; try { require('./kidlimits').give(kid.id, mins, true); } catch {} } }
|
||||
save(); if (kid) inbox.add(kid.id, { kind: 'request', title: (yes ? 'Yes: ' : 'Not this time: ') + r.text, body: r.note || (yes ? me.name + ' said yes' : me.name + ' said no'), url: '#/home', tag: 'ans:' + r.id });
|
||||
if (kid) { try { require('./push').notifyUrgent(kid.id, { title: (yes ? '✅ Yes' + (r.minutes && r.time ? ': ' + r.minutes + ' more minutes' : '') : 'Not this time'), body: r.note || r.text, url: '#/home', tag: 'ans:' + r.id }).catch(() => {}); } catch {} }
|
||||
return json(res, 200, r);
|
||||
}
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e instanceof SyntaxError ? 400 : 500, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
|
||||
}
|
||||
module.exports = { handle, askTime };
|
||||
/** A request nobody has answered keeps nudging the parents: again after 5 minutes, then every 10, up to 4 more times. */
|
||||
function nag(now = Date.now()) {
|
||||
let n = 0; for (const r of db()) if (r.status === 'open' && (r.nags || 0) < 4 && now - (r.nagAt || r.at) >= ((r.nags || 0) ? 10 : 5) * 60e3) { r.nags = (r.nags || 0) + 1; r.nagAt = now; n++; tellParents(r, '⏰ Still waiting: ' + r.userName + (r.time ? ' is asking for time' : ' is asking')); }
|
||||
if (n) save(); return n;
|
||||
}
|
||||
const nagTimer = setInterval(() => { try { nag(); } catch {} }, 60e3); nagTimer.unref();
|
||||
module.exports = { handle, askTime, nag };
|
||||
@@ -15,6 +15,8 @@ assert.equal(kid.lockedFor(users.get(sam.id), '/api/kid/status'), null, 'except
|
||||
assert.throws(() => kid.give(pat.id, 15), /not a child/); st = kid.give(sam.id, 20); assert.equal(st.locked, null, 'more time unlocks it'); assert.equal(st.limit, 35);
|
||||
// bedtime
|
||||
users.update(sam.id, { kid: { on: true, limitMin: 0, bedtime: { on: true, from: '00:00', to: '23:59' } } }); assert.equal(kid.status(users.get(sam.id)).locked, 'bedtime'); users.update(sam.id, { kid: { on: true, limitMin: 0, bedtime: { on: false } } }); assert.equal(kid.status(users.get(sam.id)).locked, null, 'no limit, no bedtime: never locked'); assert.equal(kid.status(users.get(sam.id)).left, null);
|
||||
// a yes at bedtime opens the app for the minutes given, then it locks again (and the lift survives a change of day)
|
||||
users.update(sam.id, { kid: { on: true, limitMin: 0, bedtime: { on: true, from: '00:00', to: '23:59' } } }); st = kid.give(sam.id, 15, true); assert.equal(st.locked, null, 'a yes at bedtime lets them in'); t += 14 * 60e3; assert.equal(kid.status(users.get(sam.id)).locked, null, 'still open inside the 15 minutes'); t += 2 * 60e3; assert.equal(kid.status(users.get(sam.id)).locked, 'bedtime', 'locked again afterwards'); assert.equal(kid.give(sam.id, 15).locked, 'bedtime', 'extra minutes alone do not lift bedtime (only a parent\'s yes does)');
|
||||
Date.now = real;
|
||||
// over HTTP: who may do what, and asking a parent
|
||||
const hub = http.createServer((req, res) => { const who = { sam, ann, pat }[req.headers['x-user'] || 'sam']; users.runAs(users.get(who.id), () => kid.handle(req, res, users.get(who.id), { askParents: chat.askParents })); }).listen(0, async () => {
|
||||
@@ -23,8 +25,7 @@ const hub = http.createServer((req, res) => { const who = { sam, ann, pat }[req.
|
||||
assert.equal((await j(await call('/status'))).on, true); assert.equal((await post('/ping', {})).status, 200); assert.equal((await post('/extra', { user: sam.id, minutes: 10 }, 'sam')).status, 403, 'a child cannot give themselves time'); assert.equal((await post('/extra', { user: sam.id, minutes: 10 }, 'ann')).status, 200);
|
||||
assert.equal((await call('/usage?user=' + sam.id, {}, 'sam')).status, 403); assert.equal((await j(await call('/usage?user=' + sam.id, {}, 'ann'))).on, true);
|
||||
assert.equal((await post('/ask', { text: 'More time please?' }, 'pat')).status, 400, 'only child accounts ask'); const r = await j(await post('/ask', { text: 'Can I have 15 more minutes? I am nearly done.' })); assert.equal(r.told, 1, 'the one admin is told');
|
||||
const dm = 'd:' + [sam.id, ann.id].sort().join(':'); const conv = await asAdmin(users.get(ann.id), async () => { const out = {}; await chat.handle({ url: '/api/chat/conv/' + encodeURIComponent(dm), method: 'GET', headers: {} }, { writeHead() {}, end(b) { out.j = JSON.parse(b); }, headersSent: false }); return out.j; });
|
||||
assert.equal(conv.msgs[0].text, 'Can I have 15 more minutes? I am nearly done.'); assert.equal(conv.msgs[0].name, 'Sam', 'it arrives as a chat message from the child');
|
||||
const open = JSON.parse(fs.readFileSync(path.join(process.env.DATA_DIR, 'kid-requests.json'), 'utf8')).filter(q => q.time && q.status === 'open'); assert.equal(open.length, 1, 'the ask is one open request for the parents (and one urgent notification, not a chat message too)'); assert.equal(open[0].text, 'Can I have 15 more minutes? I am nearly done.');
|
||||
console.log('kidlimits ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
|
||||
|
||||
Reference in new issue
Block a user