From b1c720c74f061ba5cd4fe56db6215de21404f01d Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:03:43 -0400 Subject: [PATCH] Time requests from the lock screen: one request with +15/+30/+1 h buttons, lock lifts quickly - Ask a parent on the lock screen also makes a request parents answer in Settings > Family - Parents choose how many minutes; the child's lock screen rechecks every few seconds - Notification links open Settings > Family - Test updated Co-Authored-By: Claude Sonnet 5.5 --- activity.js | 2 +- kidlimits.js | 3 ++- public/app.js | 4 ++-- public/settings-family.js | 4 ++-- public/settings.js | 3 ++- requests.js | 13 ++++++++++--- tools/kidcontrols-test.js | 3 +++ 7 files changed, 22 insertions(+), 10 deletions(-) diff --git a/activity.js b/activity.js index f010aa9..404f416 100644 --- a/activity.js +++ b/activity.js @@ -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: '#/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 }); } /** Once a week (Monday morning) every parent gets a short summary of each child's week in their Notifications. */ diff --git a/kidlimits.js b/kidlimits.js index e081149..922eae2 100644 --- a/kidlimits.js +++ b/kidlimits.js @@ -46,6 +46,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); } 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. */ @@ -64,7 +65,7 @@ async function handle(req, res, me, hooks = {}) { 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; return json(res, 200, { ok: true, told: n }); } + 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 === '/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' }); diff --git a/public/app.js b/public/app.js index d4fa730..e93b3ed 100644 --- a/public/app.js +++ b/public/app.js @@ -402,7 +402,7 @@ 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(); }; - await tick(); kidUi.timer = setInterval(tick, 60e3); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') tick(); }); + kidUi.tick = tick; await tick(); kidUi.timer = setInterval(tick, 60e3); 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'; } @@ -435,6 +435,6 @@ function kidLock(st) { if (document.getElementById('kidlock')) return; try { closeSheet(); } catch {} try { pl.a.pause(); } catch {} const el = document.createElement('div'); el.id = 'kidlock'; el.setAttribute('role', 'alertdialog'); el.innerHTML = `
${st.locked === 'bedtime' ? '๐ŸŒ™' : 'โฐ'}

${st.locked === 'bedtime' ? 'It is bedtime' : 'Time is up for today'}

${st.locked === 'bedtime' ? 'The hub opens again at ' + esc(st.bedtime ? st.bedtime.to : 'morning') + '.' : 'You have used all of today\'s time.'}

`; - document.body.appendChild(el); + document.body.appendChild(el); const poll = setInterval(() => { if (!document.getElementById('kidlock')) return clearInterval(poll); if (kidUi.tick) kidUi.tick(); }, 8000); /* (so the lock lifts soon after a parent adds time) */ el.querySelector('#kidask').onclick = async e => { e.currentTarget.disabled = true; try { const r = await jfetch('/api/kid/ask', { method: 'POST', body: JSON.stringify({ text: st.locked === 'bedtime' ? 'Can I stay up a little longer, please?' : 'Can I have a little more time, please?' }) }); el.querySelector('#kidmsg').textContent = r.told ? 'I asked. Wait for a yes.' : 'Nobody could be asked right now.'; } catch (err) { el.querySelector('#kidmsg').textContent = err.message; e.currentTarget.disabled = false; } }; } diff --git a/public/settings-family.js b/public/settings-family.js index b7ff697..c0e763e 100644 --- a/public/settings-family.js +++ b/public/settings-family.js @@ -28,8 +28,8 @@ async function famRequests(box) { if (!box) return; let r; try { r = await jfetch('/api/kid/requests'); } catch { box.innerHTML = ''; return; } 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 = `

Asking you (${open.length})

${open.map(q => `
${esc(q.userName)}: ${esc(q.text)}${when(q.at)}${q.tab ? ' ยท wants ' + esc((TABS.find(t => t[0] === q.tab) || [0, q.tab])[1]) : ''}${q.minutes ? ' ยท +' + q.minutes + ' min' : ''}
`).join('')}
`; - 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 }) }); toast(yes ? 'Said yes' : 'Said no'); famRequests(box); } catch (e) { toast(e.message); } }); + box.innerHTML = `

Asking you (${open.length})

${open.map(q => `
${esc(q.userName)}: ${esc(q.text)}${when(q.at)}${q.tab ? ' ยท wants ' + esc((TABS.find(t => t[0] === q.tab) || [0, q.tab])[1]) : ''}${q.minutes ? ' ยท +' + q.minutes + ' min' : ''}${q.time ? [15, 30, 60].map(m => ``).join('') : ``}
`).join('')}
`; + 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); } }); } // ---- One row per child: their week (minutes by area, what they played, searched and tried), and Plex locking. async function famKids(box) { diff --git a/public/settings.js b/public/settings.js index a09543e..8b257a8 100644 --- a/public/settings.js +++ b/public/settings.js @@ -75,7 +75,8 @@ function personSheet(u) { const del = el.querySelector('#u-del'); if (del) del.onclick = () => confirmSheet('Delete ' + u.name + '?', 'Their tasks, events, settings and sign-ins are permanently deleted.', 'Delete', () => act(() => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'DELETE' }), 'Deleted')); } -function viewSettings() { +function viewSettings(args) { + if (args && args[0]) { try { store.set('settings_tab', args[0]); } catch {} } // (a link such as #/settings/family opens that tab) const signed = !!sf.token(); const isAdmin = ME && ME.role === 'admin'; const tabNames = ME ? TABS.filter(t => ME.tabs.includes(t[0]) && !['home', 'settings'].includes(t[0])).map(t => t[1]).join(', ') : ''; diff --git a/requests.js b/requests.js index 1a003bc..42c7b02 100644 --- a/requests.js +++ b/requests.js @@ -15,6 +15,13 @@ 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); +/** 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 }); + return r; +} async function handle(req, res, me) { const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/kid/requests'.length) || '/', admin = me.role === 'admin'; try { @@ -25,17 +32,17 @@ async function handle(req, res, me) { if (!activity.isKid(me)) throw fail(400, 'This is for child accounts'); const b = JSON.parse(await readBody(req, 4e3) || '{}'), text = str(b.text, 200); if (!text) throw fail(400, 'Say what you would like'); 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', tag: 'req:' + r.id }); + 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 }); 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])] }); if (r.minutes) { try { require('./kidlimits').give(kid.id, r.minutes); } 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); } 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 }); 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 }; +module.exports = { handle, askTime }; diff --git a/tools/kidcontrols-test.js b/tools/kidcontrols-test.js index 74334be..cd833b8 100644 --- a/tools/kidcontrols-test.js +++ b/tools/kidcontrols-test.js @@ -23,6 +23,9 @@ const call = (who, url, method = 'GET', body, mod = requests) => new Promise(ok assert.equal((await call(kai, '/api/kid/requests/' + r.id, 'POST', { approve: true })).status, 403, 'a child cannot approve their own'); const before = kid.status(users.get(kai.id)).limit; assert.equal((await call(ann, '/api/kid/requests/' + r.id, 'POST', { approve: true, note: 'Weekend only' })).json.status, 'approved'); assert.ok(users.can(users.get(kai.id), 'video'), 'a yes opens the part of the hub'); assert.equal(kid.status(users.get(kai.id)).limit, before + 20, 'and adds the minutes'); assert.ok(inbox.load(kai.id).some(i => /^Yes: /.test(i.title)), 'the child is told'); r = (await call(kai, '/api/kid/requests', 'POST', { text: 'A new game' })).json; assert.equal((await call(ann, '/api/kid/requests/' + r.id, 'POST', { approve: false, note: 'Not now' })).json.status, 'denied'); assert.equal((await call(ann, '/api/kid/requests/' + r.id, 'POST', { approve: true })).status, 400, 'answered once'); + // asking for time from the lock screen makes one request the parent can answer with minutes + const t1 = requests.askTime(users.get(kai.id), 'Can I have more time?', 30), t2 = requests.askTime(users.get(kai.id), 'again', 30); assert.equal(t1.id, t2.id, 'asking twice is one request'); const lim1 = kid.status(users.get(kai.id)).limit; + assert.equal((await call(ann, '/api/kid/requests/' + t1.id, 'POST', { approve: true, minutes: 15 })).json.minutes, 15, 'the parent chooses how much'); assert.equal(kid.status(users.get(kai.id)).limit, lim1 + 15); // a reward with bonus screen time adds it when the parent marks it given users.update(kai.id, { tabs: [...users.get(kai.id).tabs, 'chores'] }); await call(ann, '/api/chores', 'POST', { title: 'Dishes', repeat: 'daily', assign: 'anyone', points: 10 }, chores); const today = new Date().toISOString().slice(0, 10), ch = (await call(ann, '/api/chores?date=' + today, 'GET', null, chores)).json.chores[0]; await call(kai, '/api/chores/done', 'POST', { chore: ch.id, date: today }, chores); const rw = (await call(ann, '/api/chores/reward', 'POST', { title: 'Extra time', points: 5, minutes: 30 }, chores)).json; assert.equal(rw.minutes, 30);