diff --git a/README.md b/README.md index bacf3bd..f2f4766 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions **Notes** (turn it on per person): notes and checklists that are private, shared with the whole family, or shared with chosen people. Anyone it is shared with can edit it. Checklist items change one at a time (tick, add, edit, remove), so two people ticking the same list never overwrite each other, ticked items say who ticked them, and an open list follows other people's changes every few seconds. Text edits are checked against a revision, so a stale edit is refused instead of overwriting someone else's. Pin, colour, archive, search and filters (lists, notes, mine, shared, archived), ready-made lists (grocery, packing, weekend to-do), *Share to Chat*, and *Send the open items to Tasks*. Only the person who made a note (or an admin) changes who it is shared with or deletes it. -**Chores** (turn it on per person): jobs that repeat daily, on 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, and the board adds up the week and month. **Weekly goals:** an admin makes reward tiers in Manage (for example 20 points: pick the movie, 40 points: ice cream). Points count Monday to Sunday and start again every Monday, with each person's total, the chores they did that week and a bar toward the next reward (also on the family board). Step back through earlier weeks with the arrows; nothing is deleted. When someone reaches a tier, an admin taps **Mark given** once the reward is handed over. Only an admin adds or changes chores. A chore given to *anyone* is a pool: a person taps **Take it** to claim it for the day (it moves under their name, and nobody else can tick it; **Put back** returns it to the pool). Kept in `data/chores.json`. +**Chores** (turn it on per person): jobs that repeat daily, on 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, and the board adds up the week and month. **Weekly goals** (for child accounts only: adults can do chores but are not scored): an admin makes reward tiers in Manage (for example 20 points: pick the movie, 40 points: ice cream). Points count Monday to Sunday and start again every Monday, with each person's total, the chores they did that week and a bar toward the next reward (also on the family board). Step back through earlier weeks with the arrows; nothing is deleted. When someone reaches a tier, an admin taps **Mark given** once the reward is handed over. Only an admin adds or changes chores. A chore given to *anyone* is a pool: a person taps **Take it** to claim it for the day (it moves under their name, and nobody else can tick it; **Put back** returns it to the pool). Kept in `data/chores.json`. **Family** (the page every account has next to Home): today and tomorrow's calendar, your chores and everyone's progress, the shopping list, shared notes and lists, recent chats and the tasks due this week. Each card only shows if the person has that part of the hub. diff --git a/chores.js b/chores.js index 00fe2e6..fb9ad25 100644 --- a/chores.js +++ b/chores.js @@ -22,7 +22,9 @@ const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFi 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 })); +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) { @@ -72,12 +74,12 @@ function summary(range, end) { /** 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 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(live).slice(0, 30) : null; if (who) { if (!who.length) throw fail(400, 'Choose who it is for'); r.for = who; } return r; }; +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'; diff --git a/public/chores-view.js b/public/chores-view.js index abc4c4c..55622e4 100644 --- a/public/chores-view.js +++ b/public/chores-view.js @@ -36,8 +36,8 @@ async function viewChores() { ${others.length ? `
${cr.week.tiers.map(t => esc(t.points) + ' pts: ' + esc(t.title) + (Array.isArray(t.for) ? ' (' + esc(t.for.map(id => (cr.data.people.find(p => p.id === id) || { name: '?' }).name).join(', ')) + ')' : '')).join(' · ')}. Points start again every Monday.
` : (d.me.admin ? 'Add rewards in Manage (for example 20 points: pick the movie) and everyone works toward them each week.
' : '')} -No chores yet.
'}${cr.week.tiers.map(t => esc(t.points) + ' pts: ' + esc(t.title) + (Array.isArray(t.for) ? ' (' + esc(t.for.map(id => (cr.data.people.find(p => p.id === id) || { name: '?' }).name).join(', ')) + ')' : '')).join(' · ')}. Points start again every Monday.
` : (d.me.admin ? 'Add rewards in Manage (for example 20 points: pick the movie) and the children work toward them each week.
' : '')} +No child accounts yet. Points and rewards are for children: turn on This is a child for a person in Settings.
'}No chores yet.
'}No rewards yet.
'}No rewards yet.
'}