Time requests from the lock screen: one request with +15/+30/+1 h buttons, lock lifts quickly
build-container / build (push) Successful in 4m9s
build-container / build (push) Successful in 4m9s
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
301e8017d9
commit
b1c720c74f
7 files changed
+22
-10
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: '#/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. */
|
||||
|
||||
+2
-1
@@ -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' });
|
||||
|
||||
+2
-2
@@ -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 = `<div class="kidbox"><div style="font-size:56px">${st.locked === 'bedtime' ? '🌙' : '⏰'}</div><h1>${st.locked === 'bedtime' ? 'It is bedtime' : 'Time is up for today'}</h1><p>${st.locked === 'bedtime' ? 'The hub opens again at ' + esc(st.bedtime ? st.bedtime.to : 'morning') + '.' : 'You have used all of today\'s time.'}</p><button class="btn" id="kidask">Ask a parent for more time</button><p class="sub" id="kidmsg"></p></div>`;
|
||||
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; } };
|
||||
}
|
||||
@@ -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 = `<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><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 }) }); toast(yes ? 'Said yes' : 'Said no'); famRequests(box); } catch (e) { toast(e.message); } });
|
||||
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); } });
|
||||
}
|
||||
// ---- One row per child: their week (minutes by area, what they played, searched and tried), and Plex locking.
|
||||
async function famKids(box) {
|
||||
|
||||
+2
-1
@@ -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(', ') : '';
|
||||
|
||||
+10
-3
@@ -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 };
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user