diff --git a/README.md b/README.md index 0b96cb9..9b942f5 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. 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:** 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/board.js b/board.js index fe3347a..393b625 100644 --- a/board.js +++ b/board.js @@ -38,7 +38,7 @@ function identify(t, pin, req) { async function board(day) { const t1 = chores.addDays(day, 1), from = new Date(day + 'T00:00:00').getTime() - 864e5, to = new Date(t1 + 'T23:59:59').getTime() + 864e5; let events = []; try { events = cal.familyEvents({ from, to, fd: day, td: t1 }); } catch {} - return { day, people: people(), announcement: chat.announcement(), events, chores: chores.board(day).filter(i => !i.user || onBoard(users.get(i.user) || {})).map(i => ({ chore: i.chore, title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done, doneBy: i.done ? i.done.byName : '', claimable: !!i.claimable, claimed: !!i.claimed })), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() }; + return { day, people: people(), announcement: chat.announcement(), events, chores: chores.board(day).filter(i => !i.user || onBoard(users.get(i.user) || {})).map(i => ({ chore: i.chore, title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done, doneBy: i.done ? i.done.byName : '', claimable: !!i.claimable, claimed: !!i.claimed })), week: (w => ({ from: w.from, tiers: w.tiers, people: w.people.filter(p => onBoard(users.get(p.id) || {})).map(p => ({ id: p.id, name: p.name, points: p.points, count: p.count, tiers: p.tiers, earned: p.earned, given: p.given })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() }; } async function handle(req, res) { diff --git a/chores.js b/chores.js index c214931..00fe2e6 100644 --- a/chores.js +++ b/chores.js @@ -18,7 +18,7 @@ const addDays = (s, n) => { const d = utc(s); d.setUTCDate(d.getUTCDate() + n); 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 : [] }; } catch { return { chores: [], log: [], claims: [] }; } })()); +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'; }; @@ -69,12 +69,22 @@ function summary(range, end) { 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, 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 === '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); @@ -101,6 +111,17 @@ async function handle(req, res) { 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 }); } @@ -108,4 +129,4 @@ async function handle(req, res) { /** 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 }; +module.exports = { handle, dueKey, board, summary, week, ymd, addDays, find }; diff --git a/public/board.js b/public/board.js index be1c56d..d5eba68 100644 --- a/public/board.js +++ b/public/board.js @@ -7,6 +7,10 @@ let data = null; function clock() { const d = new Date(); $('#clock').textContent = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); const h = d.getHours(); $('#hi').textContent = (h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening') + ', family'; $('#date').textContent = d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); } const card = (t, body) => `

${t}

${body}
`; +function weekCard(w) { + if (!w || !w.people.length) return none('No points yet'); + return w.people.map(p => { const mine = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...mine.map(t => t.points), p.points), next = mine.find(t => t.points > p.points), got = mine.filter(t => p.earned.includes(t.id)); return `
${esc(p.name)}${p.points} pts · ${p.count} done
${mine.map(t => ``).join('')}
${got.length ? '🎁 ' + esc(got.map(t => t.title).join(', ')) : ''}${next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}
`; }).join(''); +} const none = t => `

${t}

`; function draw() { @@ -20,7 +24,7 @@ function draw() { const chores = (pool.length ? `Up for grabs: tap to take one${pool.map(row).join('')}` : '') + Object.keys(per).map(k => { const l = per[k], dn = l.filter(c => c.done).length; return `
${esc(k)}${dn} of ${l.length}
${l.map(row).join('')}
`; }).join(''); $('#grid').innerHTML = card('Today and tomorrow', day('Today', t0) + day('Tomorrow', t1)) + card('Chores today', chores || none('No chores today')) - + card('Shopping list', d.shopping.map(i => `
${esc(i.text)}
`).join('') || none('Nothing to buy')) + + card('This week', weekCard(d.week)) + card('Shopping list', d.shopping.map(i => `
${esc(i.text)}
`).join('') || none('Nothing to buy')) + card('Family tasks', d.tasks.map(t => `
${esc(t.title)}${t.due === t0 ? 'Today' : esc(t.due)}
`).join('') || none('Nothing due')) + card('Family notes and lists', d.notes.map(n => `
${esc(n.title)}${n.type === 'list' ? ` ${n.done} of ${n.total} done${n.next ? ' · next: ' + esc(n.next) : ''}` : ` ${esc(n.body)}`}
`).join('') || none('No shared notes')); wireChores(); diff --git a/public/chores-view.js b/public/chores-view.js index 5e204d8..c0561e3 100644 --- a/public/chores-view.js +++ b/public/chores-view.js @@ -1,6 +1,6 @@ 'use strict'; // ---- Chores: today's chores for you and the family, ticking them off, points for the week, and (admin) making and changing chores. See chores.js. -const cr = { day: '', data: null, range: 'week', sum: null }; +const cr = { day: '', data: null, wk: '', week: null }; const crApi = (p, o) => jfetch('/api/chores' + p, o); const crPost = (p, b) => crApi(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }); const crYmd = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); @@ -9,10 +9,20 @@ const crDayLabel = s => s === crYmd(new Date()) ? 'Today' : s === crShift(crYmd( const CR_REPEAT = { daily: 'Every day', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', once: 'One time' }; const CR_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const crMonday = s => crShift(s, -((new Date(s + 'T12:00:00').getDay() + 6) % 7)); +const crWeekLabel = w => { const f = new Date(w.from + 'T12:00:00'), t = new Date(w.to + 'T12:00:00'), o = { month: 'short', day: 'numeric' }; return (w.from === crMonday(crYmd(new Date())) ? 'This week · ' : '') + f.toLocaleDateString(undefined, o) + ' to ' + t.toLocaleDateString(undefined, o); }; +/** One person's week: points, how far to the next reward, the rewards reached (an admin marks each as handed over), and the chores they did. */ +function crWeekPerson(p) { + const w = cr.week, admin = cr.data.me.admin, tiers = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...tiers.map(t => t.points), p.points), next = tiers.find(t => t.points > p.points); + return `
${esc(sgInitial(p.name))}${esc(p.name)}
${tiers.map(t => ``).join('')}
${p.points} pts · ${p.count} done
+ ${next ? `${next.points - p.points} more for ${esc(next.title)}` : tiers.length ? 'Every reward reached 🎉' : ''} + ${p.earned.map(id => { const t = w.tiers.find(x => x.id === id), given = p.given.includes(id); return `
🎁${esc(t.title)}${given ? 'Given' : 'Earned, not given yet'}${admin ? `` : ''}
`; }).join('')} + ${p.done.length ? `
Chores done (${p.done.length})${p.done.map(c => `
${esc(c.title)}${esc(new Date(c.date + 'T12:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }))}${c.pts ? c.pts + ' pts' : ''}
`).join('')}
` : ''}
`; +} async function viewChores() { const v = $('#view'); cr.day = cr.day || crYmd(new Date()); v.innerHTML = '

Chores

Loading…

'; - try { [cr.data, cr.sum] = await Promise.all([crApi('/?date=' + cr.day), crApi('/summary?range=' + cr.range + '&date=' + cr.day)]); } catch (e) { v.innerHTML = `

Chores

${esc(e.message)}

`; return; } + try { cr.wk = cr.wk || crYmd(new Date()); [cr.data, cr.week] = await Promise.all([crApi('/?date=' + cr.day), crApi('/week?date=' + cr.wk)]); } catch (e) { v.innerHTML = `

Chores

${esc(e.message)}

`; return; } if (!location.hash.startsWith('#/chores')) return; const d = cr.data, me = d.me.id, mine = d.board.filter(i => i.user === me), anyone = d.board.filter(i => i.user === null), others = d.people.filter(p => p.id !== me); const item = i => ``; @@ -23,12 +33,15 @@ async function viewChores() {

Mine

${mine.map(item).join('') || '

Nothing for you ' + (cr.day === crYmd(new Date()) ? 'today' : 'on this day') + '. 🎉

'}
${anyone.length ? `

Anyone can do

${anyone.map(item).join('')}
` : ''} ${others.length ? `

The family

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

Points

WeekMonth
-
${cr.sum.people.map((p, i) => `
${i + 1}${esc(p.name)}
${p.points} pts · ${p.count} done
`).join('') || '

No chores yet.

'}
`; +

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.

'}
`; 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(); }; - v.querySelectorAll('[data-r]').forEach(a => a.onclick = e => { e.preventDefault(); cr.range = a.dataset.r; viewChores(); }); + $('#wkprev').onclick = () => { cr.wk = crShift(cr.week.from, -7); viewChores(); }; $('#wknext').onclick = () => { cr.wk = crShift(cr.week.from, 7); viewChores(); }; if ($('#wktoday')) $('#wktoday').onclick = () => { cr.wk = crYmd(new Date()); viewChores(); }; + v.querySelectorAll('[data-give]').forEach(b => b.onclick = async () => { const [u, r] = b.dataset.give.split('|'); try { await crPost('/give', { week: cr.week.from, user: u, reward: r, undo: !!b.dataset.undo }); viewChores(); } catch (e) { toast(e.message); } }); if ($('#crman')) $('#crman').onclick = crManage; } @@ -37,10 +50,21 @@ function crManage() { const d = cr.data, who = c => c.assign === 'anyone' ? 'Anyone' : d.people.filter(p => c.assign.includes(p.id)).map(p => p.name).join(', '); 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.

'}
+
`); + 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); }; el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { closeSheet(); crEdit(d.chores.find(c => c.id === b.dataset.e)); }); } +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('')}
+
${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(); viewChores(); } catch (e) { toast(e.message); } }; + el.querySelector('#rwf').onsubmit = async e => { e.preventDefault(); try { await crPost('/reward', { id: r && r.id, title: el.querySelector('#rwt').value, points: +el.querySelector('#rwp').value, for: el.querySelector('#rwall').checked ? 'all' : [...el.querySelectorAll('#rwpp [data-p]:checked')].map(i => i.dataset.p) }); closeSheet(); viewChores(); } catch (x) { el.querySelector('#rwe').textContent = x.message; } }; +} function crEdit(c) { const d = cr.data, x = c || { title: '', notes: '', repeat: 'daily', days: [], dom: 1, date: crYmd(new Date()), assign: 'anyone', points: 0, active: true }, chosen = new Set(x.assign === 'anyone' ? [] : x.assign); const el = openSheet(c ? 'Edit chore' : 'New chore', '', `
diff --git a/public/style.css b/public/style.css index 8e2db8f..87bf6bc 100644 --- a/public/style.css +++ b/public/style.css @@ -1060,3 +1060,6 @@ body.vizopen { overflow: hidden; } #devchip { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 8px); right: 10px; z-index: 60; display: flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 22px; padding: 4px 4px 4px 14px; box-shadow: 0 4px 14px rgba(0,0,0,.3); } #devchip .btn { min-height: 34px; padding: 0 14px; border-radius: 18px; } .devwho { display: flex; align-items: center; gap: 6px; } .devkid { font-style: normal; background: #2f9e6e; color: #fff; border-radius: 8px; padding: 1px 7px; font-size: 11px; font-weight: 700; } + +.crtick { position: absolute; top: 0; width: 2px; height: 100%; background: var(--text); opacity: .55; } .crearn { display: flex; align-items: center; gap: 10px; margin: 6px 0 0 52px; padding: 8px 10px; border-radius: 12px; background: var(--panel2); } .crearn .btn { min-height: 34px; padding: 0 12px; } +.crdet { margin: 6px 0 0 52px; } .crdet summary { cursor: pointer; color: var(--muted); font-size: 14px; } .crwk { padding: 8px 0; border-bottom: 1px solid var(--line); } .crwk:last-child { border-bottom: 0; } diff --git a/public/sw.js b/public/sw.js index 8497f00..2127d2b 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-v75'; +const CACHE = 'hub-v77'; 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 2970cb3..eb6d5cf 100644 --- a/tools/chores-test.js +++ b/tools/chores-test.js @@ -36,6 +36,14 @@ 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'); + // 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); + const wb = wk.people.find(x => x.name === 'Bob'), wy = wk.people.find(x => x.name === 'Cy'); assert.ok(wb.points >= 8 && wb.done.some(x => x.title === 'Fix the gate'), 'what Bob did this week is listed'); assert.ok(wb.earned.includes(r5.id) && !wb.earned.includes(r50.id), 'tiers reached'); assert.equal(wb.given.length, 0); + assert.equal((await post('/give', { user: P.b.id, reward: r50.id }, 'a')).status, 400, 'cannot give what is not earned'); assert.equal((await post('/give', { user: P.b.id, reward: r5.id }, 'b')).status, 403); assert.equal((await post('/give', { user: P.b.id, reward: r5.id })).status, 200); wk = await j(await call('/week?date=' + today, {}, 'a')); assert.deepEqual(wk.people.find(x => x.name === 'Bob').given, [r5.id]); + assert.equal((await j(await call('/week?date=' + chores.addDays(today, -7), {}, 'a'))).people.every(x => x.points === 0), true, 'last week had no points: they start again each week'); + const rc = await j(await post('/reward', { title: 'Bake together', points: 3, for: [P.c.id] })); wk = await j(await call('/week?date=' + today, {}, 'a')); assert.ok(wk.people.find(x => x.name === 'Cy').earned.includes(rc.id), 'a reward just for Cy: Cy earns it'); assert.ok(!wk.people.find(x => x.name === 'Bob').tiers.includes(rc.id), 'and it is not on Bob\'s list'); assert.equal((await post('/reward', { title: 'x', points: 3, for: [] })).status, 400, 'someone has to be chosen'); await post('/reward', { id: rc.id, remove: true }); + assert.equal((await post('/give', { user: P.b.id, reward: r5.id, undo: true })).status, 200); assert.equal((await j(await call('/week?date=' + today, {}, 'a'))).people.find(x => x.name === 'Bob').given.length, 0); // delete removes its history assert.equal((await call('/' + once.id, { method: 'DELETE' }, 'b')).status, 403); assert.equal((await call('/' + once.id, { method: 'DELETE' })).status, 200); assert.equal((await j(await call('/summary', {}, 'a'))).people.find(x => x.name === 'Bob').points, 3); assert.equal((await call('/?date=nope')).status, 400);