Chores, Family page, genre search, child accounts, wider Search and Notifications center
build-container / build (push) Successful in 4m6s
build-container / build (push) Successful in 4m6s
- Chores tab with repeats, points and a week/month board - Family page: calendar, chores, shopping, notes, chat and tasks on one start page - Genre search in the Store, Free Books and LibriVox - Child accounts: safe content, daily limit and bedtime, ask a parent - Search now covers notes, chat, contacts, chores, recipes and your books - Notifications center: bell with a per-person list of chat, reminders and announcements - Roadmap, README and tests updated Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf292e3bc7
commit
0383da87f5
38 files changed
+720
-79
No files matched your search
@@ -0,0 +1,50 @@
|
||||
// Child accounts: a daily time limit and a bedtime, enforced here on the server (so a child cannot get round them by changing the device's clock or reloading), with a way to ask
|
||||
// a parent for more time. Time counts while the app is open and visible (the app pings once a minute). A locked account can still reach its own status and the "ask a parent" button.
|
||||
const fs = require('fs'), path = require('path');
|
||||
const atomicWrite = require('./atomic');
|
||||
const users = require('./users');
|
||||
const push = require('./push');
|
||||
const { readBody } = require('./body');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'kid-usage.json');
|
||||
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 load = () => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return j && typeof j === 'object' ? j : {}; } catch { return {}; } };
|
||||
const save = d => atomicWrite(FILE(), JSON.stringify(d), { mode: 0o600 });
|
||||
const hm = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
/** What a child account may be set to: { on, limitMin (0 = no limit), bedtime: { on, from, to }, safe }. */
|
||||
function cleanKid(k) {
|
||||
if (!k || typeof k !== 'object') return undefined; const n = Math.round(+k.limitMin) || 0, b = k.bedtime || {};
|
||||
return { on: !!k.on, limitMin: n <= 0 ? 0 : Math.max(15, Math.min(720, n)), bedtime: { on: !!b.on, from: hm.test(b.from) ? b.from : '20:30', to: hm.test(b.to) ? b.to : '07:00' }, safe: k.safe !== false };
|
||||
}
|
||||
const tzOf = uid => { try { return JSON.parse(fs.readFileSync(path.join(users.dirOf(uid), 'push.json'), 'utf8')).tz || process.env.HUB_TZ || 'UTC'; } catch { return process.env.HUB_TZ || 'UTC'; } };
|
||||
/** Where this child stands right now. */
|
||||
function status(u) {
|
||||
const k = u && u.kid; if (!k || !k.on || u.role === 'admin') return { on: false };
|
||||
const now = push.localNow(tzOf(u.id)), day = load()[u.id] && load()[u.id][now.day] || { secs: 0, extra: 0 }, limit = k.limitMin ? k.limitMin + (day.extra || 0) : 0, used = Math.floor((day.secs || 0) / 60);
|
||||
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;
|
||||
return { on: true, used, limit, left: limit ? Math.max(0, limit - used) : null, extra: day.extra || 0, bedtime: q.on ? { from: q.from, to: q.to } : null, locked, day: now.day };
|
||||
}
|
||||
function ping(u) {
|
||||
const k = u && u.kid; if (!k || !k.on || u.role === 'admin') return status(u); const st = status(u); if (st.locked) return st;
|
||||
const d = load(), now = push.localNow(tzOf(u.id)), mine = d[u.id] || (d[u.id] = {}), day = mine[now.day] || (mine[now.day] = { secs: 0, extra: 0, last: 0 }), t = Date.now();
|
||||
day.secs += day.last ? Math.min(90, Math.max(0, (t - day.last) / 1000)) : 30; day.last = t; for (const k2 of Object.keys(mine)) if (k2 < new Date(t - 40 * 864e5).toISOString().slice(0, 10)) delete mine[k2]; save(d); return 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. */
|
||||
function lockedFor(u, pathname) { if (!u || !u.kid || !u.kid.on || u.role === 'admin' || ALLOWED_WHILE_LOCKED.test(pathname)) return null; const st = status(u); return st.locked ? st : null; }
|
||||
|
||||
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') return json(res, 200, ping(me));
|
||||
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 === '/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' });
|
||||
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
|
||||
}
|
||||
module.exports = { handle, status, ping, give, lockedFor, cleanKid };
|
||||
Reference in new issue
Block a user