Files
hub/chores.js
T
nmemmertandClaude Sonnet 5.5 17a282054a
build-container / build (push) Successful in 3m53s
Chores: weekly goals with custom reward tiers (everyone or chosen people)
- Admin-set reward tiers; points count Monday to Sunday and restart each week
- Per-person weekly totals, chores done, progress bar toward the next reward, week history
- Mark given / undo when a reward is handed over
- Rewards can be for everyone or only chosen people
- "This week" card on the family board
- Tests and README updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:46:44 -04:00

133 lines
15 KiB
JavaScript

// Chores: jobs that repeat (daily, weekdays, on chosen days of the week, weekly, monthly) or happen once, given to one or more people or to "anyone". Each person ticks off their
// own; an admin can tick for someone. Finished chores earn points, which the family board adds up for the week and month. Kept in DATA_DIR/chores.json.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'chores.json');
const REPEATS = ['daily', 'weekdays', 'weekly', 'monthly', 'once'], MAX_CHORES = 300, MAX_LOG = 50000;
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 str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
const newId = () => crypto.randomBytes(6).toString('hex');
const ymdOk = s => /^\d{4}-\d{2}-\d{2}$/.test(s || '') && !Number.isNaN(Date.parse(s + 'T12:00:00Z'));
const utc = s => new Date(s + 'T12:00:00Z');
const ymd = d => d.toISOString().slice(0, 10);
const addDays = (s, n) => { const d = utc(s); d.setUTCDate(d.getUTCDate() + n); return ymd(d); };
const weekday = s => utc(s).getUTCDay(); // 0 = Sunday
const monday = s => addDays(s, -((weekday(s) + 6) % 7));
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 : [] }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [] }; } })());
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 nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chores')).map(u => ({ id: u.id, name: u.name, board: u.board !== false }));
/** The period a chore is due in on this day (the key its completion is filed under), or null when it is not due. Weekly chores with no chosen days, monthly and one-off chores stay due until done. */
function dueKey(c, day) {
if (!c.active || (c.start && day < c.start)) return null; const wd = weekday(day);
switch (c.repeat) {
case 'daily': return day;
case 'weekdays': return wd >= 1 && wd <= 5 ? day : null;
case 'weekly': return c.days && c.days.length ? (c.days.includes(wd) ? day : null) : monday(day);
case 'monthly': { const dom = c.dom || 1, [y, m, d] = day.split('-').map(Number), last = new Date(Date.UTC(y, m, 0)).getUTCDate(); return d >= Math.min(dom, last) ? day.slice(0, 7) : null; }
case 'once': return c.date && day >= c.date ? 'once' : null;
default: return null;
}
}
const assignees = c => Array.isArray(c.assign) ? c.assign.filter(live) : [];
/** Everything due on a day, for everyone: one entry per person it is given to (or one for "anyone"), with who finished it. */
function board(day) {
const out = [];
for (const c of db().chores) {
const key = dueKey(c, day); if (!key) continue; const who = assignees(c);
const anyone = c.assign === 'anyone' || !who.length, claim = anyone ? db().claims.find(x => x.chore === c.id && x.key === key && live(x.user)) : null; // (someone has taken this one from the pool)
const targets = anyone ? [claim ? claim.user : null] : who;
for (const u of targets) {
const done = db().log.find(l => l.chore === c.id && l.key === key && (anyone || l.user === u));
if (c.repeat === 'once' && !done && c.date && c.date > day) continue; // (a one-off is shown from its day on)
out.push({ chore: c.id, key, title: c.title, notes: c.notes, points: c.points, repeat: c.repeat, user: u, userName: u ? nameOf(u) : 'Anyone', claimed: !!claim, claimable: anyone && !claim, done: done ? { by: done.by, byName: nameOf(done.by), at: done.at, pts: done.pts } : null, overdue: !done && c.repeat === 'once' && c.date < day });
}
}
return out.sort((a, b) => (a.done ? 1 : 0) - (b.done ? 1 : 0) || a.title.localeCompare(b.title));
}
function clean(b, old) {
const title = str(b.title, 120); if (!title) throw fail(400, 'Give the chore a name');
const repeat = REPEATS.includes(b.repeat) ? b.repeat : 'daily'; const c = { id: old ? old.id : newId(), title, notes: str(b.notes, 400), repeat, points: Math.max(0, Math.min(50, Math.round(+b.points) || 0)), active: b.active === false ? false : true, created: old ? old.created : Date.now(), start: ymdOk(b.start) ? b.start : (old ? old.start : undefined) };
c.assign = b.assign === 'anyone' ? 'anyone' : [...new Set((Array.isArray(b.assign) ? b.assign : []).map(String))].filter(live).slice(0, 20);
if (c.assign !== 'anyone' && !c.assign.length) c.assign = 'anyone';
if (repeat === 'weekly') c.days = [...new Set((Array.isArray(b.days) ? b.days : []).map(Number).filter(d => d >= 0 && d <= 6))].sort();
if (repeat === 'monthly') c.dom = Math.max(1, Math.min(31, Math.round(+b.dom) || 1));
if (repeat === 'once') { if (!ymdOk(b.date)) throw fail(400, 'Choose the day for this one-off chore'); c.date = b.date; }
return c;
}
/** Points and counts per person over a stretch of days ending on `end`. */
function summary(range, end) {
const days = range === 'month' ? 30 : 7, from = addDays(end, -(days - 1)), per = new Map(people().map(p => [p.id, { id: p.id, name: p.name, points: 0, count: 0, perDay: Array(days).fill(0) }]));
for (const l of db().log) { if (l.date < from || l.date > end) continue; const e = per.get(l.by) || (live(l.by) ? per.set(l.by, { id: l.by, name: nameOf(l.by), points: 0, count: 0, perDay: Array(days).fill(0) }).get(l.by) : null); if (!e) continue; e.points += l.pts || 0; e.count++; e.perDay[Math.round((utc(l.date) - utc(from)) / 864e5)]++; }
return { range, from, to: end, people: [...per.values()].sort((a, b) => b.points - a.points || b.count - a.count) };
}
/** One calendar week (Monday to Sunday): each person's points, the chores they did, which reward tiers they reached, and which rewards were handed over. Points start again each Monday; the history stays. */
const forMe = (t, uid) => !Array.isArray(t.for) || t.for.includes(uid); // a reward is for everyone, or for chosen people
function week(day) {
const from = monday(day), to = addDays(from, 6), d = db(), tiers = d.rewards.slice().sort((a, b) => a.points - b.points), per = new Map(people().map(p => [p.id, { id: p.id, name: p.name, points: 0, count: 0, done: [] }]));
for (const l of d.log) { if (l.date < from || l.date > to) continue; const e = per.get(l.by) || (live(l.by) ? per.set(l.by, { id: l.by, name: nameOf(l.by), points: 0, count: 0, done: [] }).get(l.by) : null); if (!e) continue; const c = d.chores.find(x => x.id === l.chore); e.points += l.pts || 0; e.count++; e.done.push({ title: c ? c.title : 'A chore that was removed', date: l.date, pts: l.pts || 0 }); }
const out = [...per.values()].map(e => ({ ...e, done: e.done.sort((a, b) => a.date.localeCompare(b.date)), tiers: tiers.filter(t => forMe(t, e.id)).map(t => t.id), earned: tiers.filter(t => forMe(t, e.id) && e.points >= t.points).map(t => t.id), given: d.given.filter(g => g.week === from && g.user === e.id).map(g => g.reward) })).sort((a, b) => b.points - a.points || b.count - a.count || a.name.localeCompare(b.name));
return { from, to, tiers, people: out };
}
const cleanReward = (b, old) => { const title = str(b.title, 80); if (!title) throw fail(400, 'Say what the reward is'); const points = Math.round(+b.points); if (!(points >= 1 && points <= 1000)) throw fail(400, 'Choose the points needed (1 to 1000)'); const r = { id: old ? old.id : newId(), points, title }; const who = Array.isArray(b.for) ? [...new Set(b.for.map(String))].filter(live).slice(0, 30) : null; if (who) { if (!who.length) throw fail(400, 'Choose who it is for'); r.for = who; } return r; };
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/chores'.length) || '/', me = users.current(), q = u.searchParams, admin = me.role === 'admin';
try {
let m;
const day = q.get('date') || ymd(new Date()); if (!ymdOk(day)) throw fail(400, 'Bad date');
if (p === '/' && req.method === 'GET') return json(res, 200, { me: { id: me.id, admin }, day, rewards: db().rewards.slice().sort((a, b) => a.points - b.points), people: people(), chores: db().chores, board: board(day).map(i => ({ ...i, mine: i.user === me.id || i.user === null, canDo: admin || i.user === me.id || i.user === null })) });
if (p === '/' && req.method === 'POST') {
if (!admin) throw fail(403, 'Only an admin can add or change chores'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), old = b.id ? db().chores.find(x => x.id === b.id) : null; if (b.id && !old) throw fail(404, 'Chore not found'); if (!old && db().chores.length >= MAX_CHORES) throw fail(400, 'That is plenty of chores');
const c = clean(b, old); if (old) db().chores[db().chores.indexOf(old)] = c; else db().chores.push(c); save(); return json(res, 200, c);
}
if ((m = /^\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { if (!admin) throw fail(403, 'Only an admin can delete chores'); const d = db(); d.chores = d.chores.filter(x => x.id !== m[1]); d.log = d.log.filter(l => l.chore !== m[1]); d.claims = d.claims.filter(x => x.chore !== m[1]); save(); return json(res, 200, { ok: true }); }
if (p === '/done' && req.method === 'POST') { // tick a chore off (or take the tick 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 today = ymd(new Date()); if (date > addDays(today, 1) || date < addDays(today, -7)) throw fail(400, 'That day is out of range');
const key = dueKey(c, date); if (!key) throw fail(400, 'That chore is not due on that day');
const who = c.assign === 'anyone' ? null : String(b.user || me.id); if (who && !assignees(c).includes(who)) throw fail(400, 'That chore is not given to that person');
const taken = !who && db().claims.find(x => x.chore === c.id && x.key === key && live(x.user)); if (taken && taken.user !== me.id && !admin) throw fail(403, nameOf(taken.user) + ' took this one');
if (who && who !== me.id && !admin) throw fail(403, 'You can only tick off your own chores');
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 (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 });
}
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');
if (!(c.assign === 'anyone' || !assignees(c).length)) throw fail(400, 'That chore is given to particular people'); const key = dueKey(c, date); if (!key) throw fail(400, 'That chore is not due on that day');
const d = db(), i = d.claims.findIndex(x => x.chore === c.id && x.key === key);
if (b.undo) { if (i < 0) return json(res, 200, { ok: true }); if (d.claims[i].user !== me.id && !admin) throw fail(403, 'Only the person who took it can put it back'); d.claims.splice(i, 1); save(); return json(res, 200, { ok: true }); }
if (d.log.some(l => l.chore === c.id && l.key === key)) throw fail(400, 'That one is already done');
if (i >= 0 && live(d.claims[i].user)) { if (d.claims[i].user === me.id) return json(res, 200, { ok: true, already: true }); throw fail(409, nameOf(d.claims[i].user) + ' already took this one'); }
if (i >= 0) d.claims.splice(i, 1); d.claims.push({ chore: c.id, key, user: me.id, at: Date.now() }); if (d.claims.length > 2000) d.claims.splice(0, d.claims.length - 2000); save(); return json(res, 200, { ok: true });
}
if (p === '/week' && req.method === 'GET') return json(res, 200, week(day));
if (p === '/reward' && req.method === 'POST') { // make, change or remove a reward tier (admin)
if (!admin) throw fail(403, 'Only an admin can change the rewards'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), d = db(), old = b.id ? d.rewards.find(x => x.id === b.id) : null;
if (b.id && !old) throw fail(404, 'Reward not found'); if (b.remove) { d.rewards = d.rewards.filter(x => x !== old); d.given = d.given.filter(g => g.reward !== b.id); save(); return json(res, 200, { ok: true }); }
if (!old && d.rewards.length >= 12) throw fail(400, 'That is plenty of rewards'); const r = cleanReward(b, old); if (old) d.rewards[d.rewards.indexOf(old)] = r; else d.rewards.push(r); save(); return json(res, 200, r);
}
if (p === '/give' && req.method === 'POST') { // note that a reward was handed over (admin), or take it back
if (!admin) throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), d = db(), wk = monday(ymdOk(b.week) ? b.week : day), i = d.given.findIndex(g => g.week === wk && g.user === String(b.user) && g.reward === String(b.reward));
if (b.undo) { if (i >= 0) d.given.splice(i, 1); save(); return json(res, 200, { ok: true }); }
const w = week(wk), e = w.people.find(x => x.id === String(b.user)); if (!e || !e.earned.includes(String(b.reward))) throw fail(400, 'They have not earned that one'); if (i < 0) d.given.push({ week: wk, user: e.id, reward: String(b.reward), at: Date.now(), by: me.id }); if (d.given.length > 3000) d.given.splice(0, d.given.length - 3000); save(); return json(res, 200, { ok: true });
}
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' });
} 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 }); }
}
/** Chores whose name or notes mention the words. */
const find = q => db().chores.filter(c => (c.title + ' ' + c.notes).toLowerCase().includes(q)).map(c => ({ id: c.id, title: c.title, repeat: c.repeat, who: c.assign === 'anyone' ? 'Anyone' : assignees(c).map(nameOf).join(', '), points: c.points }));
module.exports = { handle, dueKey, board, summary, week, ymd, addDays, find };