diff --git a/board.js b/board.js index 25b1c08..22fc98c 100644 --- a/board.js +++ b/board.js @@ -48,7 +48,7 @@ function eventFields(title, b) { 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 })), 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() }; + 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, goal: p.goal })) }))(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 ea17fe2..bd2a72d 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 : [], rewards: Array.isArray(j.rewards) ? j.rewards : [], given: Array.isArray(j.given) ? j.given : [] }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [] }; } })()); +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 : [], goals: j.goals && typeof j.goals === 'object' && !Array.isArray(j.goals) ? j.goals : {}, reminded: j.reminded && typeof j.reminded === 'object' && !Array.isArray(j.reminded) ? j.reminded : {} }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [], goals: {}, reminded: {} }; } })()); 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'; }; @@ -76,7 +76,7 @@ const forMe = (t, uid) => !Array.isArray(t.for) || t.for.includes(uid); // a rew 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)); + 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), goal: (g => tiers.some(t => t.id === g && forMe(t, e.id)) ? g : null)((d.goals[e.id] || {})[from]) })).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 bonus = Math.max(0, Math.min(240, Math.round(+b.minutes) || 0)), r = { id: old ? old.id : newId(), points, title }; if (bonus) r.minutes = bonus; /* (optional: extra screen time that comes with the reward) */ 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; }; @@ -86,7 +86,7 @@ async function handle(req, res) { 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 === 'GET') return json(res, 200, { me: { id: me.id, admin, kid: isKid(me) }, 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); @@ -116,7 +116,7 @@ async function handle(req, res) { 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 (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); for (const k of Object.keys(d.goals)) for (const w of Object.keys(d.goals[k])) if (d.goals[k][w] === b.id) delete d.goals[k][w]; 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 @@ -124,11 +124,22 @@ async function handle(req, res) { 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 }); const rw = d.rewards.find(x => x.id === String(b.reward)); if (rw && rw.minutes) { try { require('./kidlimits').give(e.id, rw.minutes); } catch {} } } /* (a reward with bonus screen time adds it to today) */ if (d.given.length > 3000) d.given.splice(0, d.given.length - 3000); save(); return json(res, 200, { ok: true }); } + if (p === '/goal' && req.method === 'POST') { // a child chooses the reward they are aiming for (an admin can choose for a child); no reward clears it + const b = JSON.parse(await readBody(req, 2e3) || '{}'), d = db(), uid = admin && b.user ? String(b.user) : me.id; + if (!liveKid(uid)) throw fail(400, 'Goals are for child accounts'); + const near = ymdOk(b.date) && Math.abs(Date.parse(b.date + 'T12:00:00Z') - Date.now()) < 3 * 864e5, wk = monday(near ? b.date : ymd(new Date())); // (this week, by the child's own calendar; a goal is for one week and starts empty each Monday) + if (!b.reward) { if (d.goals[uid]) delete d.goals[uid][wk]; save(); return json(res, 200, { ok: true, goal: null }); } + const t = d.rewards.find(x => x.id === String(b.reward)); if (!t || !forMe(t, uid)) throw fail(400, 'That reward is not available'); const mine = d.goals[uid] = d.goals[uid] || {}; mine[wk] = t.id; for (const w of Object.keys(mine).sort().slice(0, -12)) delete mine[w]; save(); try { require('./inbox').markTagRead(uid, 'goal:' + wk); } catch {} return json(res, 200, { ok: true, goal: t.id }); + } 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 }); } } +/** The Monday of this week, when a child with rewards to choose from still has no goal for it and has not been reminded yet (otherwise null). */ +function goalNeeded(uid, day) { const d = db(), wk = monday(day); if (!liveKid(uid) || d.reminded[uid] === wk || (d.goals[uid] || {})[wk]) return null; return d.rewards.some(t => forMe(t, uid)) ? wk : null; } +function goalReminded(uid, wk) { db().reminded[uid] = wk; save(); } + /** 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 }; +module.exports = { handle, dueKey, board, summary, week, ymd, addDays, find, goalNeeded, goalReminded }; diff --git a/public/board.js b/public/board.js index 16a4ee8..89677f4 100644 --- a/public/board.js +++ b/public/board.js @@ -9,7 +9,7 @@ function clock() { const d = new Date(); $('#clock').textContent = d.toLocaleTim const card = (t, body, add, cls) => `

${t}${add ? `` : ''}

${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(''); + 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), goal = mine.find(t => t.id === p.goal), 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(', ')) : ''}${goal ? (got.length ? ' 路 ' : '') + (p.points >= goal.points ? '馃幆 ' + esc(goal.title) + ' reached!' : '馃幆 ' + (goal.points - p.points) + ' more for ' + esc(goal.title)) : next ? (got.length ? ' 路 ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}
`; }).join(''); } const none = t => `

${t}

`; diff --git a/public/chores-view.js b/public/chores-view.js index 16dd13c..21b492b 100644 --- a/public/chores-view.js +++ b/public/chores-view.js @@ -13,12 +13,25 @@ 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 馃帀' : ''} + 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), goal = tiers.find(t => t.id === p.goal), canPick = tiers.length && (p.id === cr.data.me.id || admin); + return `
${esc(sgInitial(p.name))}${esc(p.name)}
${tiers.map(t => ``).join('')}
${p.points} pts 路 ${p.count} done
+ ${goal ? `馃幆 ${p.points >= goal.points ? esc(goal.title) + ': reached! 馃帀' : (goal.points - p.points) + ' more for ' + esc(goal.title)}` : next ? `${next.points - p.points} more for ${esc(next.title)}` : tiers.length ? 'Every reward reached 馃帀' : ''} + ${canPick ? `` : ''} ${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('')}
` : ''}
`; } +/** A child's own "aiming for" card at the top of Chores: the reward they chose and how far they have to go this week. */ +function crGoalCard() { + const me = cr.data.me.id, p = cr.week.people.find(x => x.id === me); if (!p || !p.tiers.length) return ''; + const t = cr.week.tiers.find(x => x.id === p.goal), now = cr.week.from === crMonday(crYmd(new Date())); + return `
馃幆Aiming for${t ? esc(t.title) : 'Pick a reward to aim for'}${t && now ? `${p.points >= t.points ? 'You did it! 馃帀' : (t.points - p.points) + ' more points this week'}` : ''}
`; +} +function crPickGoal(uid) { + const p = cr.week.people.find(x => x.id === uid); if (!p) return; const tiers = cr.week.tiers.filter(t => p.tiers.includes(t.id)); + const el = openSheet('What are you aiming for?', uid === cr.data.me.id ? 'Pick a reward for this week. Chores shows how far you have to go. You pick again each Monday.' : 'Choose a goal for ' + p.name, `
${tiers.map(t => ``).join('')}
${p.goal ? '
' : ''}`); + const set = async r => { try { await crPost('/goal', { reward: r, user: uid, date: crYmd(new Date()) }); if (typeof inboxSettled === 'function') inboxSettled([{ tag: 'goal:' + crMonday(crYmd(new Date())) }]); closeSheet(); viewChores(); } catch (e) { toast(e.message); } }; + el.querySelectorAll('[data-g]').forEach(b => b.onclick = () => set(b.dataset.g)); const n = el.querySelector('#crgno'); if (n) n.onclick = () => set(''); +} /** After a change in the manage sheets: redraw the Chores page, or (when opened from Settings) reopen the manager. */ const crRefresh = () => location.hash.startsWith('#/chores') ? viewChores() : (cr.afterEdit && cr.afterEdit()); async function viewChores() { @@ -31,6 +44,7 @@ async function viewChores() { const person = p => { const l = d.board.filter(i => i.user === p.id), done = l.filter(i => i.done).length; return `
${esc(sgInitial(p.name))}${esc(p.name)}${l.length ? done + ' of ' + l.length : 'nothing today'}
${l.length ? `
` : ''}
${l.map(item).join('')}
`; }; v.innerHTML = `

Chores

${d.me.admin ? `` : ''}
${esc(crDayLabel(cr.day))}${cr.day !== crYmd(new Date()) ? '' : ''}
+ ${crGoalCard()}

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('')}
` : ''} @@ -43,6 +57,7 @@ async function viewChores() { $('#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(); }; $('#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 ($('#crgoal')) $('#crgoal').onclick = () => crPickGoal(cr.data.me.id); v.querySelectorAll('.crgoalbtn').forEach(b => b.onclick = () => crPickGoal(b.dataset.goal)); if ($('#crman')) $('#crman').onclick = crManage; } diff --git a/public/style.css b/public/style.css index 27506c5..f7f9c7a 100644 --- a/public/style.css +++ b/public/style.css @@ -1072,6 +1072,9 @@ body.vizopen { overflow: hidden; } .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; } +.crtick.goal { width: 4px; background: var(--accent); opacity: 1; } +.crgoal { display: flex; align-items: center; gap: 12px; margin: 8px 0 4px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); } .crgoal .crgi { font-size: 26px; } .crgoal .grow { min-width: 0; } .crgoal b, .crgoal small { display: block; } .crgoal small { color: var(--muted); } +.crgoalbtn { margin: 4px 0 0 52px; min-height: 36px; font-size: 14px; } #kidreq { position: fixed; left: 12px; bottom: calc(env(safe-area-inset-bottom, 0px) + 74px + var(--playerh, 0px)); z-index: 55; min-height: 40px; padding: 0 16px; border-radius: 22px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; font-weight: 600; box-shadow: 0 4px 14px rgba(0,0,0,.3); cursor: pointer; } @media (min-width: 900px) { #kidreq { bottom: calc(16px + var(--playerh, 0px)); left: 236px; } } diff --git a/push.js b/push.js index 0916a1c..c1b1c84 100644 --- a/push.js +++ b/push.js @@ -10,6 +10,7 @@ const tasks = require('./tasks'); const cal = require('./cal'); const mail = require('./mail'); const inbox = require('./inbox'); +const chores = require('./chores'); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > 20000) { no(new Error('Too large')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); @@ -46,6 +47,14 @@ async function send(dir, st, uid, payload) { if (changed) { st.subs = st.subs.filter(s => !s.dead); save(dir, st); } } +/** A child with no reward goal for this week gets one reminder (in the Notifications list, and on their devices if they have any), in the daytime by their own clock. */ +async function goalReminder(u, dir, st, now) { + const lt = localNow(st.subs.length ? st.tz : Intl.DateTimeFormat().resolvedOptions().timeZone, now); if (lt.hm < '08:00' || lt.hm >= '20:00') return; + const wk = chores.goalNeeded(u.id, lt.day); if (!wk) return; + chores.goalReminded(u.id, wk); + await send(dir, st, u.id, { title: 'Pick your reward for this week', body: 'What are you aiming for? Choose a reward on the Chores page.', url: '#/chores', tag: 'goal:' + wk }); +} + let lastEvents = 0; let lastDigest = 0; /** One pass over everyone: runs every minute. */ @@ -56,6 +65,7 @@ async function tick(now = new Date(), force = false) { for (const u of users.all()) { if (u.disabled) continue; const dir = users.dirOf(u.id), st = load(dir); + await goalReminder(u, dir, st, now).catch(e => console.warn('[push] goal reminder failed:', e.message)); if (!st.subs.length) continue; await users.runAs(u, async () => { const lt = localNow(st.tz, now); let dirty = false; diff --git a/tools/chores-test.js b/tools/chores-test.js index 22f47e8..36d1d70 100644 --- a/tools/chores-test.js +++ b/tools/chores-test.js @@ -1,7 +1,8 @@ // Chores: when each kind of chore is due, who it is given to, ticking off and undoing, who may tick what, and the points for the week. const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'); +process.env.TZ = 'UTC'; // (the reminder goes out in the daytime by the server's clock) process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-chores-')); -const users = require('../users'), chores = require('../chores'); +const users = require('../users'), chores = require('../chores'), push = require('../push'), inbox = require('../inbox'); 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 () => { @@ -47,6 +48,28 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] 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); + // a child chooses the reward they are aiming for + const rcy = await j(await post('/reward', { title: 'Cy only', points: 4, for: [P.c.id] })); + const goalOf = async who => (await j(await call('/week?date=' + today, {}, 'a'))).people.find(x => x.name === who).goal; + assert.equal((await post('/goal', { reward: r50.id }, 'b')).status, 200); assert.equal(await goalOf('Bob'), r50.id, 'Bob is aiming for the trip'); + assert.equal((await post('/goal', { reward: rcy.id }, 'b')).status, 400, 'not one that is only for Cy'); assert.equal((await post('/goal', { reward: 'nope' }, 'b')).status, 400); + assert.equal((await post('/goal', { reward: r5.id, user: P.c.id }, 'b')).status, 200); assert.equal(await goalOf('Bob'), r5.id, 'a child only ever sets their own'); assert.equal(await goalOf('Cy'), null); + assert.equal((await post('/goal', { reward: rcy.id, user: P.c.id }, 'a')).status, 200); assert.equal(await goalOf('Cy'), rcy.id, 'an admin can choose for a child'); + assert.equal((await post('/goal', { reward: r5.id }, 'a')).status, 400, 'goals are for children'); + const tmp = await j(await post('/reward', { title: 'Temp', points: 9 })); await post('/goal', { reward: tmp.id }, 'b'); assert.equal(await goalOf('Bob'), tmp.id); await post('/reward', { id: tmp.id, remove: true }); assert.equal(await goalOf('Bob'), null, 'a removed reward is no longer anyone\'s goal'); + await post('/goal', { reward: r5.id }, 'b'); assert.equal((await post('/goal', { reward: '' }, 'b')).status, 200); assert.equal(await goalOf('Bob'), null, 'no reward clears it'); + assert.equal((await j(await call('/', {}, 'b'))).me.kid, true, 'the page knows who is a child'); + // a goal is for one week and starts empty each Monday; a child with none is reminded once + const wkGoal = async (who, date) => (await j(await call('/week?date=' + date, {}, 'a'))).people.find(x => x.name === who).goal, thisMon = (await j(await call('/week?date=' + today, {}, 'a'))).from; + await post('/goal', { reward: r5.id }, 'b'); assert.equal(await wkGoal('Bob', today), r5.id); assert.equal(await wkGoal('Bob', chores.addDays(today, 7)), null, 'next week starts empty'); assert.equal(await wkGoal('Bob', chores.addDays(today, -7)), null, 'and last week had none'); + assert.equal(chores.goalNeeded(P.b.id, today), null, 'a goal is chosen: no reminder'); await post('/goal', { reward: '' }, 'b'); + assert.equal(chores.goalNeeded(P.b.id, today), thisMon, 'no goal yet: a reminder is due'); assert.equal(chores.goalNeeded(P.a.id, today), null, 'not for adults'); + const later = chores.addDays(thisMon, 14), at = new Date(Date.parse(later + 'T10:00:00Z')), mine = () => inbox.load(P.b.id).filter(i => i.tag === 'goal:' + later); + await push.tick(at, true); assert.equal(mine().length, 1, 'a child with no goal is reminded in the daytime'); assert.equal(inbox.load(P.a.id).some(i => /^goal:/.test(i.tag)), false, 'not an adult'); + await push.tick(new Date(at.getTime() + 60e3), true); assert.equal(mine()[0].count, 1, 'only once a week'); + await post('/goal', { reward: '', user: P.c.id }); await push.tick(new Date(Date.parse(thisMon + 'T10:00:00Z') + 2 * 864e5), true); const cyRem = () => inbox.load(P.c.id).filter(i => i.tag === 'goal:' + thisMon); assert.equal(cyRem().length, 1); assert.equal(cyRem()[0].read, false); + await post('/goal', { reward: rcy.id }, 'c'); assert.equal(cyRem()[0].read, true, 'choosing a goal settles the reminder'); + const early = new Date(Date.parse(chores.addDays(later, 7) + 'T03:00:00Z')); await push.tick(early, true); assert.equal(inbox.load(P.b.id).some(i => i.tag === 'goal:' + chores.addDays(later, 7)), false, 'not in the middle of the night'); // 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);