Files
hub/activity.js
T
nmemmertandClaude Sonnet 5.5 b1c720c74f
build-container / build (push) Successful in 4m9s
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 <noreply@anthropic.com>
2026-10-26 17:03:43 -04:00

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: 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 };