// 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 isKid = u => !!(u.kid && u.kid.on); // points and rewards are for child accounts only const liveKid = uid => live(uid) && isKid(users.get(uid)); const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chores')).map(u => ({ id: u.id, name: u.name, board: u.board !== false, kid: isKid(u) })); /** 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().filter(p => p.kid).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) || (liveKid(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(liveKid).slice(0, 30) : null; if (who) { if (!who.length) throw fail(400, 'Choose who it is for (child accounts)'); 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 };