// 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: 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. */ 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 };