diff --git a/board.js b/board.js index 22fc98c..ee9599b 100644 --- a/board.js +++ b/board.js @@ -62,6 +62,12 @@ async function handle(req, res) { const body = JSON.stringify({ chore: b.chore, date: ymdOk(b.date) ? b.date : undefined, undo: b.action === 'unclaim' || b.action === 'undo' || undefined }), r = Readable.from([Buffer.from(body)]); r.url = path; r.method = 'POST'; r.headers = {}; return users.runAs(t, () => chores.handle(r, res)); // (as that person: all of the chore rules apply to them) } + if (p === '/api/board/goal' && req.method === 'POST') { // a child chooses the reward they are aiming for this week, from the shared screen + if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 2e3) || '{}'), t = users.get(String(b.uid)); + if (!t || !onBoard(t)) throw fail(404, 'Person not found'); if (!users.can(t, 'chores')) throw fail(403, 'Chores are not turned on for that person'); identify(t, b.pin); + const r = Readable.from([Buffer.from(JSON.stringify({ reward: String(b.reward || ''), date: ymdOk(b.date) ? b.date : undefined }))]); r.url = '/api/chores/goal'; r.method = 'POST'; r.headers = {}; + return users.runAs(t, () => chores.handle(r, res)); // (as that person: the same rules as choosing it in Chores) + } if (p === '/api/board/add' && req.method === 'POST') { // put something on one of the family's lists from the shared screen, as the person who picked their name if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 8e3) || '{}'), t = users.get(String(b.uid)); if (!t || !onBoard(t)) throw fail(404, 'Person not found'); const text = String(b.text || '').replace(/\s+/g, ' ').trim().slice(0, 200); if (!text) throw fail(400, 'Write something first'); diff --git a/public/board.js b/public/board.js index 2c4fa27..c50bcfd 100644 --- a/public/board.js +++ b/public/board.js @@ -9,7 +9,20 @@ 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), 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(''); + return w.people.map(p => { const mine = w.tiers.filter(t => p.tiers.includes(t.id)), goal = mine.find(t => t.id === p.goal), top = Math.max(1, goal ? goal.points : 0, p.points); return `
${esc(p.name)}${p.points} pts · ${p.count} done
${goal ? `` : ''}
${goal ? `${p.points >= goal.points ? '🎯 ' + esc(goal.title) + ' reached! 🎉' : '🎯 ' + (goal.points - p.points) + ' more for ' + esc(goal.title)}` : mine.length ? `` : ''}
`; }).join(''); +} +// ---- Choose a reward to aim for: pick it from a pop-up, then PIN (unless a child with no PIN), and it is set as that person +function goalFlow(pid) { + const p = data.people.find(x => x.id === pid), w = data.week; if (!p || !w) return; const mine = w.tiers.filter(t => ((w.people.find(x => x.id === pid) || {}).tiers || []).includes(t.id)); + const { box, close } = sheet(`

${esc(p.name.split(' ')[0])}, what are you aiming for?

Pick one reward for this week.

${mine.map(t => ``).join('')}

`); + box.querySelector('#no').onclick = close; + const send = async (reward, pin) => { const r = await fetch('/api/board/goal', { method: 'POST', body: JSON.stringify({ uid: p.id, pin, reward, date: ymd(new Date()) }) }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'That did not work'); close(); await load(); toast('Goal chosen'); }; + box.querySelectorAll('[data-g]').forEach(b => b.onclick = () => { const reward = b.dataset.g; + if (p.kid && !p.pin) return send(reward).catch(e => { box.querySelector('#e').textContent = e.message; }); + if (!p.pin) { box.querySelector('#e').textContent = p.name + ' has not set a PIN yet. Set one in Settings on your own device.'; return; } + let pin = ''; box.innerHTML = `

${esc(p.name)}, enter your PIN

${[1, 2, 3, 4, 5, 6, 7, 8, 9, '⌫', 0, '✓'].map(k => ``).join('')}

`; + const paint = () => { box.querySelector('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => ``).join(''); }; paint(); box.querySelector('#no').onclick = close; + box.querySelectorAll('.pad button').forEach(k2 => k2.onclick = async () => { let k = k2.dataset.k; box.querySelector('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { try { await send(reward, pin); return; } catch (e) { box.querySelector('#e').textContent = e.message; pin = ''; } } paint(); }); }); } const none = t => `

${t}

`; @@ -33,7 +46,7 @@ function poolAll() { const list = data.chores.filter(c => c.claimable && !c.done), { box, close } = sheet(`

Up for grabs

${list.length} chores. Tap one to pick it

${list.map(c => ``).join('')}

`); box.querySelector('#no').onclick = close; box.querySelectorAll('.pill[data-c]').forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c && (c.user || '') === (b.dataset.u || '')))); } -function wireChores() { document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFlow(b.dataset.add)); const all = document.getElementById('poolall'); if (all) all.onclick = poolAll; document.querySelectorAll(".pill[data-c]").forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c && (c.user || '') === (b.dataset.u || '')))); } +function wireChores() { document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFlow(b.dataset.add)); document.querySelectorAll('[data-goalfor]').forEach(b => b.onclick = () => goalFlow(b.dataset.goalfor)); const all = document.getElementById('poolall'); if (all) all.onclick = poolAll; document.querySelectorAll(".pill[data-c]").forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c && (c.user || '') === (b.dataset.u || '')))); } async function load() { try { const r = await fetch('/api/board?day=' + ymd(new Date())); if (r.status === 403) { location.replace('/login'); return; } data = await r.json(); draw(); } catch {} } function pick(p) { diff --git a/public/sw.js b/public/sw.js index d43dc60..6bbe0cb 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-v143'; +const CACHE = 'hub-v144'; const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version 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/board-test.js b/tools/board-test.js index cdecf04..3ae4a07 100644 --- a/tools/board-test.js +++ b/tools/board-test.js @@ -24,6 +24,8 @@ const hub = http.createServer((req, res) => req.url.startsWith('/api/chores') ? assert.equal((await act(kid.id, 'claim')).status, 200, 'Kai takes it with one tap'); cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.claimed, true); assert.equal(cb.userName, 'Kai'); assert.equal((await act(bob.id, 'done')).status, 403, 'Bob has no PIN, so he cannot act as himself yet'); assert.equal((await act(kid.id, 'done')).status, 200); cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.done, true); assert.equal(cb.doneBy, 'Kai'); assert.equal((await act(kid.id, 'undo')).status, 200); assert.equal((await post('/api/board/chore', { uid: kid.id, chore: ch.id, action: 'done' }, '')).status, 403, 'only a family device can do this'); assert.equal((await act(kid.id, 'nope')).status, 400); + const rw = (await post('/api/chores/reward', { title: 'Movie night', points: 20 }, 'hub_session=' + session(ann).slice(12))).json; assert.ok(rw && rw.id, 'a reward exists'); const gl = (b, who = dev) => post('/api/board/goal', { uid: kid.id, date: today, ...b }, who); + assert.equal((await gl({ reward: rw.id }, '')).status, 403, 'only a family device can choose'); assert.equal((await gl({ reward: rw.id })).status, 200, 'Kai chooses a goal from the family screen'); assert.equal(((await call('/api/board?day=' + today, {}, dev)).json.week.people.find(p => p.id === kid.id) || {}).goal, rw.id, 'and the board shows it as the goal'); assert.equal((await gl({ reward: 'nope' })).status, 400); const add = b => post('/api/board/add', { uid: kid.id, ...b }, dev), day0 = new Date().toISOString().slice(0, 10); assert.equal((await add({ kind: 'task', text: 'Water the plants', due: day0 })).status, 200, 'Kai adds a family task'); assert.ok((await call('/api/tasks?list=local:family', {}, 'hub_session=' + session(ann).slice(12))).status !== 500); assert.ok(JSON.stringify((await call('/api/board?day=' + day0, {}, dev)).json.tasks).includes('Water the plants'), 'and it shows on the board'); assert.equal((await add({ kind: 'event', text: 'Movie night', date: day0, time: '19:00', tz: 'UTC' })).status, 200); assert.ok(JSON.stringify((await call('/api/board?day=' + day0, {}, dev)).json.events).includes('Movie night'), 'a timed event lands on the family calendar');