Chores: parents (and the child) are notified when a child's points reach their chosen reward
build-container / build (push) Canceled after 0s
build-container / build (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
132a8eab5d
commit
3aaff55ade
1 file changed
+13
-3
@@ -18,7 +18,7 @@ const addDays = (s, n) => { const d = utc(s); d.setUTCDate(d.getUTCDate() + n);
|
|||||||
const weekday = s => utc(s).getUTCDay(); // 0 = Sunday
|
const weekday = s => utc(s).getUTCDay(); // 0 = Sunday
|
||||||
const monday = s => addDays(s, -((weekday(s) + 6) % 7));
|
const monday = s => addDays(s, -((weekday(s) + 6) % 7));
|
||||||
let cache = null;
|
let cache = null;
|
||||||
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return { chores: Array.isArray(j.chores) ? j.chores : [], log: Array.isArray(j.log) ? j.log : [], claims: Array.isArray(j.claims) ? j.claims : [], rewards: Array.isArray(j.rewards) ? j.rewards : [], given: Array.isArray(j.given) ? j.given : [], goals: j.goals && typeof j.goals === 'object' && !Array.isArray(j.goals) ? j.goals : {}, reminded: j.reminded && typeof j.reminded === 'object' && !Array.isArray(j.reminded) ? j.reminded : {} }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [], goals: {}, reminded: {} }; } })());
|
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return { chores: Array.isArray(j.chores) ? j.chores : [], log: Array.isArray(j.log) ? j.log : [], claims: Array.isArray(j.claims) ? j.claims : [], rewards: Array.isArray(j.rewards) ? j.rewards : [], given: Array.isArray(j.given) ? j.given : [], goals: j.goals && typeof j.goals === 'object' && !Array.isArray(j.goals) ? j.goals : {}, reminded: j.reminded && typeof j.reminded === 'object' && !Array.isArray(j.reminded) ? j.reminded : {}, hit: Array.isArray(j.hit) ? j.hit : [] }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [], goals: {}, reminded: {}, hit: [] }; } })());
|
||||||
const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); };
|
const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); };
|
||||||
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chores'); };
|
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chores'); };
|
||||||
const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
|
const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
|
||||||
@@ -102,7 +102,7 @@ async function handle(req, res) {
|
|||||||
const idx = db().log.findIndex(l => l.chore === c.id && l.key === key && (who === null || l.user === who));
|
const idx = db().log.findIndex(l => l.chore === c.id && l.key === key && (who === null || l.user === who));
|
||||||
if (b.undo) { if (idx < 0) return json(res, 200, { ok: true }); const l = db().log[idx]; if (l.by !== me.id && !admin) throw fail(403, 'Only the person who ticked it can take it back'); db().log.splice(idx, 1); save(); return json(res, 200, { ok: true }); }
|
if (b.undo) { if (idx < 0) return json(res, 200, { ok: true }); const l = db().log[idx]; if (l.by !== me.id && !admin) throw fail(403, 'Only the person who ticked it can take it back'); db().log.splice(idx, 1); save(); return json(res, 200, { ok: true }); }
|
||||||
if (idx >= 0) return json(res, 200, { ok: true, already: true });
|
if (idx >= 0) return json(res, 200, { ok: true, already: true });
|
||||||
const by = who || me.id, entry = { chore: c.id, key, date, user: who, by, at: Date.now(), pts: c.points }; db().log.push(entry); if (db().log.length > MAX_LOG) db().log.splice(0, db().log.length - MAX_LOG); save(); return json(res, 200, { ok: true, pts: c.points });
|
const by = who || me.id, entry = { chore: c.id, key, date, user: who, by, at: Date.now(), pts: c.points }; db().log.push(entry); if (db().log.length > MAX_LOG) db().log.splice(0, db().log.length - MAX_LOG); save(); goalReached(by, date); return json(res, 200, { ok: true, pts: c.points });
|
||||||
}
|
}
|
||||||
if (p === '/claim' && req.method === 'POST') { // take a chore from the "anyone" pool (or put it back)
|
if (p === '/claim' && req.method === 'POST') { // take a chore from the "anyone" pool (or put it back)
|
||||||
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = db().chores.find(x => x.id === String(b.chore)), date = b.date || ymd(new Date()); if (!c) throw fail(404, 'Chore not found'); if (!ymdOk(date)) throw fail(400, 'Bad date');
|
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = db().chores.find(x => x.id === String(b.chore)), date = b.date || ymd(new Date()); if (!c) throw fail(404, 'Chore not found'); if (!ymdOk(date)) throw fail(400, 'Bad date');
|
||||||
@@ -129,13 +129,23 @@ async function handle(req, res) {
|
|||||||
if (!liveKid(uid)) throw fail(400, 'Goals are for child accounts');
|
if (!liveKid(uid)) throw fail(400, 'Goals are for child accounts');
|
||||||
const near = ymdOk(b.date) && Math.abs(Date.parse(b.date + 'T12:00:00Z') - Date.now()) < 3 * 864e5, wk = monday(near ? b.date : ymd(new Date())); // (this week, by the child's own calendar; a goal is for one week and starts empty each Monday)
|
const near = ymdOk(b.date) && Math.abs(Date.parse(b.date + 'T12:00:00Z') - Date.now()) < 3 * 864e5, wk = monday(near ? b.date : ymd(new Date())); // (this week, by the child's own calendar; a goal is for one week and starts empty each Monday)
|
||||||
if (!b.reward) { if (d.goals[uid]) delete d.goals[uid][wk]; save(); return json(res, 200, { ok: true, goal: null }); }
|
if (!b.reward) { if (d.goals[uid]) delete d.goals[uid][wk]; save(); return json(res, 200, { ok: true, goal: null }); }
|
||||||
const t = d.rewards.find(x => x.id === String(b.reward)); if (!t || !forMe(t, uid)) throw fail(400, 'That reward is not available'); const mine = d.goals[uid] = d.goals[uid] || {}; mine[wk] = t.id; for (const w of Object.keys(mine).sort().slice(0, -12)) delete mine[w]; save(); try { require('./inbox').markTagRead(uid, 'goal:' + wk); } catch {} return json(res, 200, { ok: true, goal: t.id });
|
const t = d.rewards.find(x => x.id === String(b.reward)); if (!t || !forMe(t, uid)) throw fail(400, 'That reward is not available'); const mine = d.goals[uid] = d.goals[uid] || {}; mine[wk] = t.id; for (const w of Object.keys(mine).sort().slice(0, -12)) delete mine[w]; save(); try { require('./inbox').markTagRead(uid, 'goal:' + wk); } catch {} goalReached(uid, wk); return json(res, 200, { ok: true, goal: t.id });
|
||||||
}
|
}
|
||||||
if (p === '/summary' && req.method === 'GET') return json(res, 200, summary(q.get('range') === 'month' ? 'month' : 'week', day));
|
if (p === '/summary' && req.method === 'GET') return json(res, 200, summary(q.get('range') === 'month' ? 'month' : 'week', day));
|
||||||
return json(res, 404, { error: 'Not found' });
|
return json(res, 404, { error: 'Not found' });
|
||||||
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
|
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A child's points have reached the reward they are aiming for: tell the parents (so they can hand it over) and the child, once per reward per week. */
|
||||||
|
function goalReached(uid, day) {
|
||||||
|
try {
|
||||||
|
if (!liveKid(uid)) return; const d = db(), wk = monday(day), e = week(wk).people.find(x => x.id === uid), t = e && e.goal && d.rewards.find(x => x.id === e.goal); if (!t || e.points < t.points) return;
|
||||||
|
const key = uid + '|' + wk + '|' + t.id; if (d.hit.includes(key)) return; d.hit.push(key); if (d.hit.length > 500) d.hit.splice(0, d.hit.length - 500); save();
|
||||||
|
const kid = users.get(uid), inbox = require('./inbox'), push = require('./push'), body = t.title + ' (' + t.points + ' pts)' + (t.minutes ? ' · +' + t.minutes + ' min screen time' : '') + '. Ready to give.';
|
||||||
|
for (const a of users.all()) if (!a.disabled && a.role === 'admin') { inbox.add(a.id, { kind: 'kid', title: '🎉 ' + kid.name + ' reached their goal', body, url: '#/chores', tag: 'reached:' + key }); push.notifyUrgent(a.id, { title: '🎉 ' + kid.name + ' reached their goal', body, url: '#/chores', tag: 'reached:' + key }).catch(() => {}); }
|
||||||
|
inbox.add(uid, { kind: 'info', title: '🎉 You reached your goal!', body: t.title + '. A parent will give it to you.', url: '#/chores', tag: 'reached:' + key }); push.notifyUrgent(uid, { title: '🎉 You reached your goal!', body: t.title, url: '#/chores', tag: 'reached:' + key }).catch(() => {});
|
||||||
|
} catch (e) { console.warn('[chores] goal notice:', e.message); }
|
||||||
|
}
|
||||||
/** The Monday of this week, when a child with rewards to choose from still has no goal for it and has not been reminded yet (otherwise null). */
|
/** The Monday of this week, when a child with rewards to choose from still has no goal for it and has not been reminded yet (otherwise null). */
|
||||||
function goalNeeded(uid, day) { const d = db(), wk = monday(day); if (!liveKid(uid) || d.reminded[uid] === wk || (d.goals[uid] || {})[wk]) return null; return d.rewards.some(t => forMe(t, uid)) ? wk : null; }
|
function goalNeeded(uid, day) { const d = db(), wk = monday(day); if (!liveKid(uid) || d.reminded[uid] === wk || (d.goals[uid] || {})[wk]) return null; return d.rewards.some(t => forMe(t, uid)) ? wk : null; }
|
||||||
function goalReminded(uid, wk) { db().reminded[uid] = wk; save(); }
|
function goalReminded(uid, wk) { db().reminded[uid] = wk; save(); }
|
||||||
|
|||||||
Reference in new issue
Block a user