Chores, Family page, genre search, child accounts, wider Search and Notifications center
build-container / build (push) Successful in 4m6s
build-container / build (push) Successful in 4m6s
- Chores tab with repeats, points and a week/month board - Family page: calendar, chores, shopping, notes, chat and tasks on one start page - Genre search in the Store, Free Books and LibriVox - Child accounts: safe content, daily limit and bedtime, ask a parent - Search now covers notes, chat, contacts, chores, recipes and your books - Notifications center: bell with a per-person list of chat, reminders and announcements - Roadmap, README and tests updated Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf292e3bc7
commit
0383da87f5
38 files changed
+720
-79
No files matched your search
@@ -0,0 +1,100 @@
|
||||
// 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 : [] }; } catch { return { chores: [], log: [] }; } })());
|
||||
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 }));
|
||||
|
||||
/** 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 targets = c.assign === 'anyone' || !who.length ? [null] : who;
|
||||
for (const u of targets) {
|
||||
const done = db().log.find(l => l.chore === c.id && l.key === key && (u === null || 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', 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) };
|
||||
}
|
||||
|
||||
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, 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]); 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');
|
||||
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 === '/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, ymd, addDays, find };
|
||||
Reference in new issue
Block a user