build-container / build (push) Successful in 3m52s
- Sign-in page shows name tiles on remembered devices; tap a name, then PIN or passkey - Music and Movies are separate on the server; Plex can be locked to a child's managed profile - Activity log, weekly summary and alerts to parents; per-area and weekend limits - Requests from children (Ask button) answered in Settings; bonus screen time from rewards - Restricted Learn search (Simple English Wikipedia, blocked words) replaces web search for children - Simple mode (big-button home) for young children or grandparents - Child usage kept in memory instead of read from disk on every request - Tests and README updated Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
47 lines
4.4 KiB
JavaScript
47 lines
4.4 KiB
JavaScript
// What a child does, for the parents: a short log of what they played, read, searched and when they signed in, plus alerts to the parents when something needs them
|
|
// (the time is up, a request, something blocked). Kept in DATA_DIR/activity.json. Chat messages are never logged here.
|
|
const fs = require('fs'), path = require('path');
|
|
const atomicWrite = require('./atomic');
|
|
const users = require('./users');
|
|
const inbox = require('./inbox');
|
|
|
|
const FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'activity.json');
|
|
const MAX_PER_CHILD = 800, KEEP_DAYS = 60;
|
|
let cache = null;
|
|
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return j && typeof j === 'object' ? j : {}; } catch { return {}; } })());
|
|
const save = () => { try { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); } catch (e) { console.warn('[activity]', e.message); } };
|
|
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, n);
|
|
const isKid = u => !!(u && u.kid && u.kid.on && u.role !== 'admin');
|
|
const KINDS = ['play', 'watch', 'listen', 'read', 'search', 'signin', 'chore', 'add', 'blocked', 'other'];
|
|
|
|
/** Note something a child did. The same thing twice within ten minutes counts once (a song restarting, a page reloading). */
|
|
function log(uid, kind, title) {
|
|
const u = users.get(uid); if (!isKid(u)) return; kind = KINDS.includes(kind) ? kind : 'other'; title = str(title, 160); if (!title && kind !== 'signin') return;
|
|
const l = db()[uid] || (db()[uid] = []), now = Date.now(), last = l.slice(-6).find(e => e.kind === kind && e.title === title && now - e.t < 10 * 60e3); if (last) return;
|
|
l.push({ t: now, kind, title }); const cut = now - KEEP_DAYS * 864e5; while (l.length && (l.length > MAX_PER_CHILD || l[0].t < cut)) l.shift(); save();
|
|
}
|
|
const recent = (uid, days = 7) => (db()[uid] || []).filter(e => e.t >= Date.now() - days * 864e5).sort((a, b) => b.t - a.t);
|
|
|
|
const alerted = new Map(); // child + kind -> when a parent was last told, so a repeated attempt is one alert, not twenty
|
|
/** Tell every parent (admin) in their Notifications list: `kind` is 'limit', 'request' or 'blocked'. */
|
|
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 });
|
|
}
|
|
|
|
/** Once a week (Monday morning) every parent gets a short summary of each child's week in their Notifications. */
|
|
function weeklyDigest(now = new Date()) {
|
|
const kids = users.all().filter(u => !u.disabled && isKid(u)); if (!kids.length) return false; const wk = now.toISOString().slice(0, 10), monday = new Date(now); monday.setUTCDate(monday.getUTCDate() - ((monday.getUTCDay() + 6) % 7));
|
|
const key = monday.toISOString().slice(0, 10); if (now.getUTCDay() !== 1 || now.getUTCHours() < 8 || db()._digest === key) return false; db()._digest = key; save();
|
|
const { usageByDay, AREA_LABEL } = require('./kidlimits');
|
|
for (const k of kids) {
|
|
const days = usageByDay(k.id, 7), mins = days.reduce((n, d) => n + d.minutes, 0), by = {}; for (const d of days) for (const [a, m] of Object.entries(d.areas)) by[a] = (by[a] || 0) + m;
|
|
const ev = recent(k.id, 7), count = kind => ev.filter(e => e.kind === kind).length, h = m => m >= 60 ? Math.floor(m / 60) + ' h ' + (m % 60) + ' min' : m + ' min';
|
|
const parts = [h(mins) + ' in the app', ...Object.entries(by).filter(([a, m]) => m >= 5 && AREA_LABEL[a]).map(([a, m]) => AREA_LABEL[a] + ' ' + h(m)), count('play') + count('watch') + count('listen') ? (count('play') + count('watch') + count('listen')) + ' things played' : '', count('search') ? count('search') + ' searches' : '', count('blocked') ? count('blocked') + ' blocked attempts' : ''].filter(Boolean);
|
|
for (const a of users.all()) if (!a.disabled && a.role === 'admin') inbox.add(a.id, { kind: 'kid', title: k.name + ': last week', body: parts.join(' · '), url: '#/settings', tag: 'digest:' + k.id + ':' + key });
|
|
}
|
|
return true;
|
|
}
|
|
module.exports = { log, recent, alertParents, isKid, weeklyDigest };
|