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 ? `

The family

${others.map(person).join('')}
` : ''}

Weekly goals

${esc(crWeekLabel(cr.week))}${cr.week.from !== crMonday(crYmd(new Date())) ? '' : ''}
- ${cr.week.tiers.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.

' : '')} -
${cr.week.people.map(crWeekPerson).join('') || '

No chores yet.

'}
`; + ${cr.week.tiers.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 the children work toward them each week.

' : '')} +
${cr.week.people.map(crWeekPerson).join('') || '

No child accounts yet. Points and rewards are for children: turn on This is a child for a person in Settings.

'}
`; v.querySelectorAll('.crclaim').forEach(b => b.onclick = async e => { e.preventDefault(); try { await crPost('/claim', { chore: b.dataset.c, date: cr.day, undo: !!b.dataset.undo }); if (!b.dataset.undo) toast('It is yours'); viewChores(); } catch (x) { toast(x.message); viewChores(); } }); v.querySelectorAll('.critem input').forEach(c => c.onchange = async () => { try { await crPost('/done', { chore: c.dataset.c, date: cr.day, user: c.dataset.u || undefined, undo: !c.checked }); if (c.checked) toast('Nice work!'); viewChores(); } catch (e) { toast(e.message); c.checked = !c.checked; } }); $('#crprev').onclick = () => { cr.day = crShift(cr.day, -1); viewChores(); }; $('#crnext').onclick = () => { cr.day = crShift(cr.day, 1); viewChores(); }; if ($('#crtoday')) $('#crtoday').onclick = () => { cr.day = crYmd(new Date()); viewChores(); }; @@ -52,7 +52,7 @@ function crManage() { const when = c => CR_REPEAT[c.repeat] + (c.repeat === 'weekly' && c.days && c.days.length ? ' · ' + c.days.map(x => CR_DAYS[x]).join(' ') : '') + (c.repeat === 'monthly' ? ' · day ' + (c.dom || 1) : '') + (c.repeat === 'once' ? ' · ' + c.date : ''); const el = openSheet('Manage chores', 'Make jobs for the family and choose who does them', `
${d.chores.map(c => ``).join('') || '

No chores yet.

'}
-

Weekly rewards

${d.rewards.map(r => ``).join('') || '

No rewards yet.

'}
+

Weekly rewards

${d.rewards.map(r => ``).join('') || '

No rewards yet.

'}
`); el.querySelector('#rwnew').onclick = () => { closeSheet(); crReward(null); }; el.querySelectorAll('[data-rw]').forEach(b => b.onclick = () => { closeSheet(); crReward(d.rewards.find(r => r.id === b.dataset.rw)); }); el.querySelector('#crnew').onclick = () => { closeSheet(); crEdit(null); }; @@ -60,7 +60,7 @@ function crManage() { } function crReward(r) { const el = openSheet(r ? 'Edit reward' : 'New reward', 'Anyone who reaches the points in a week earns it. Points start again each Monday.', `
-
${cr.data.people.map(p => ``).join('')}
+
${cr.data.people.filter(p => p.kid).map(p => ``).join('')}
${r ? '' : ''}
`); const rsync = () => { el.querySelector('#rwpp').hidden = el.querySelector('#rwall').checked; }; el.querySelector('#rwall').onchange = rsync; rsync(); el.querySelector('#rwx').onclick = closeSheet; if (r) el.querySelector('#rwdel').onclick = async () => { try { await crPost('/reward', { id: r.id, remove: true }); closeSheet(); crRefresh(); } catch (e) { toast(e.message); } }; diff --git a/public/sw.js b/public/sw.js index 6aaf35c..6442389 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v82'; +const CACHE = 'hub-v83'; const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/tools/chores-test.js b/tools/chores-test.js index eb6d5cf..22f47e8 100644 --- a/tools/chores-test.js +++ b/tools/chores-test.js @@ -3,6 +3,7 @@ const http = require('http'), assert = require('assert'), fs = require('fs'), os process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-chores-')); const users = require('../users'), chores = require('../chores'); const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chores'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['chores'], handle: 'Yw' }), d: users.create({ name: 'Di', role: 'user', tabs: ['music'], handle: 'ZA' }) }; +users.update(P.b.id, { kid: { on: true } }); users.update(P.c.id, { kid: { on: true } }); // (points and rewards are for child accounts) const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => chores.handle(req, res))).listen(0, async () => { const B = 'http://127.0.0.1:' + hub.address().port + '/api/chores', call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json(); const today = chores.ymd(new Date()), board = async (who, date = today) => (await j(await call('/?date=' + date, {}, who))).board; @@ -36,6 +37,8 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] assert.equal((await post('/claim', { chore: sweep.id, date: today }, 'c')).status, 409, 'someone else cannot take it too'); assert.equal((await post('/done', { chore: sweep.id, date: today }, 'c')).status, 403, 'or tick it off'); assert.equal((await post('/claim', { chore: sweep.id, date: today, undo: true }, 'c')).status, 403, 'or put it back'); assert.equal((await post('/claim', { chore: sweep.id, date: today, undo: true }, 'b')).status, 200); assert.equal((await board('b')).find(x => x.title === 'Sweep').claimable, true, 'put back in the pool'); await post('/claim', { chore: sweep.id, date: today }, 'c'); assert.equal((await post('/done', { chore: sweep.id, date: today }, 'c')).status, 200); sw = (await board('b')).find(x => x.title === 'Sweep'); assert.ok(sw.done && sw.user === P.c.id, 'done by the one who took it'); assert.equal((await post('/claim', { chore: sweep.id, date: today, undo: true }, 'c')).status, 200); assert.equal((await post('/claim', { chore: dishes.id, date: today }, 'b')).status, 400, 'only pool chores'); + // points are for children only: an adult who does a chore is not on the weekly board + assert.ok(!(await j(await call('/week?date=' + today, {}, 'a'))).people.some(x => x.name === 'Ann'), 'the admin is not in the weekly goals'); assert.equal((await post('/reward', { title: 'x', points: 3, for: [P.a.id] })).status, 400, 'rewards cannot be for an adult'); // weekly goals: tiers, a Monday-to-Sunday week, what each person did, handing a reward over assert.equal((await post('/reward', { title: 'Pick the movie', points: 5 }, 'b')).status, 403, 'only an admin sets rewards'); assert.equal((await post('/reward', { title: '', points: 5 })).status, 400); assert.equal((await post('/reward', { title: 'x', points: 0 })).status, 400); const r5 = await j(await post('/reward', { title: 'Pick the movie', points: 5 })), r50 = await j(await post('/reward', { title: 'A trip', points: 50 })); let wk = await j(await call('/week?date=' + today, {}, 'a')); assert.equal(wk.from, chores.addDays(wk.from, 0)); assert.equal(new Date(wk.from + 'T12:00:00Z').getUTCDay(), 1, 'a week starts on Monday'); assert.equal(wk.tiers.length, 2);