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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-29 08:29:47 -04:00
1 parent d0e4e82e0a
commit 0a33119807
11 files changed
+59 -19

No files matched your search

+16 -2
View File
@@ -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 };