diff --git a/board.js b/board.js index ee9599b..668c3c5 100644 --- a/board.js +++ b/board.js @@ -32,6 +32,13 @@ const ymdOk = s => /^\d{4}-\d{2}-\d{2}$/.test(s || ''); const isKid = u => !!(u.kid && u.kid.on); const palette = ['#e0742b', '#2f9e6e', '#4f7cff', '#8a5cf0', '#d9453a', '#2aa6a0', '#e5a00d']; const onBoard = u => !u.disabled && u.board !== false; // the admin picks who is on the family board +const NAME_FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'family-name.json'); +const savedName = () => { try { return String(JSON.parse(fs.readFileSync(NAME_FILE(), 'utf8')).name || ''); } catch { return ''; } }; +/** What the board calls the family: the name an admin set, or " Family" when two or more people share a last name, else "family". */ +function familyName() { + const s = savedName(); if (s) return s; const last = {}; for (const u of users.all().filter(onBoard)) { const w = String(u.name || '').trim().split(/\s+/); if (w.length > 1) { const k = w[w.length - 1]; last[k] = (last[k] || 0) + 1; } } + const top = Object.entries(last).sort((a, b) => b[1] - a[1])[0]; return top && top[1] >= 2 ? top[0] + ' Family' : 'family'; +} const people = () => users.all().filter(onBoard).map((u, i) => ({ id: u.id, name: u.name, kid: isKid(u), pin: hasPin(u.id), pinLen: (db()[u.id] || {}).n || 0, color: palette[i % palette.length] })); /** Check it is really this person: a child with no PIN just taps their name; everyone else needs their PIN. */ @@ -48,7 +55,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, goal: p.goal })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() }; + return { day, familyName: familyName(), 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) { @@ -62,6 +69,11 @@ 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/family' && req.method === 'GET') { if (!me) throw fail(403, 'Sign in first'); return json(res, 200, { name: savedName(), shown: familyName() }); } + if (p === '/api/board/family' && req.method === 'POST') { // an admin sets what the family board calls the family + if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can change this'); const b = JSON.parse(await readBody(req, 1e3) || '{}'), name = String(b.name || '').replace(/[\u0000-\u001f<>]/g, ' ').trim().slice(0, 40); + if (name) { fs.mkdirSync(path.dirname(NAME_FILE()), { recursive: true }); atomicWrite(NAME_FILE(), JSON.stringify({ name })); } else fs.rmSync(NAME_FILE(), { force: true }); return json(res, 200, { name, shown: familyName() }); + } 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); diff --git a/public/board.html b/public/board.html index e5afaa2..4ba3ba4 100644 --- a/public/board.html +++ b/public/board.html @@ -47,6 +47,7 @@ body { padding: 18px; } .bd { max-width: 1280px; margin: 0 auto; } .bdtop { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; } .bdl { flex: none; } .pickrow { flex: 1 1 320px; min-width: 0; display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; } .bdclock { flex: none; margin-left: auto; } + .cheer { margin: 6px 0 4px; padding: 10px 14px; border-radius: 14px; background: #2f9e6e; color: #fff; font-weight: 700; font-size: 18px; text-align: center; animation: cpop .35s ease-out; } @keyframes cpop { from { transform: scale(.85); opacity: 0; } to { transform: none; opacity: 1; } } @keyframes cfall { to { transform: translateY(105vh) rotate(540deg); opacity: .9; } } .chrow { display: flex; align-items: center; gap: 8px; padding: 9px 0; border-bottom: 1px solid var(--line); text-align: left; } .chrow .g { flex: 1; min-width: 0; font-size: 18px; } .chrow.dn .g { text-decoration: line-through; opacity: .6; } .chrow .btn { min-height: 44px; padding: 0 14px; } .pickrow .pk { display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-radius: 18px; background: var(--panel); border: 2px solid var(--line); color: var(--text); font: inherit; text-align: left; cursor: pointer; } .pickrow .pk.has { border-color: var(--accent); } .pickrow .pk:active { transform: scale(.97); } .pickrow .pk .av { width: 46px; height: 46px; border-radius: 50%; color: #fff; display: grid; place-items: center; font-size: 21px; font-weight: 700; flex: none; } .pickrow .pk b { display: block; font-size: 18px; } .pickrow .pk small { color: var(--muted); font-size: 13px; white-space: nowrap; } .bdtop h1 { margin: 0; font-size: 34px; } .bdclock { font-size: 40px; font-weight: 700; font-variant-numeric: tabular-nums; } diff --git a/public/board.js b/public/board.js index 9dc2d2e..b8350da 100644 --- a/public/board.js +++ b/public/board.js @@ -5,7 +5,7 @@ const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&a const ymd = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); 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' }); } +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') + ', ' + ((data && data.familyName) || 'family'); $('#date').textContent = d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); } const card = (t, body, add, cls) => `

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

${body}
`; function weekCard(w) { if (!w || !w.people.length) return none('No points yet'); @@ -47,7 +47,7 @@ function poolAll() { 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-hub]').forEach(b => b.onclick = () => { const p = data.people.find(x => x.id === b.dataset.hub); if (p && (p.kid || data.chores.some(c => c.user === p.id))) choreHub(p); else pick(p); }); 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 {} } +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(); clock(); } catch {} } function pick(p) { if (!p) return; @@ -115,15 +115,31 @@ function whoThen(title, payload, doneMsg) { function toast(msg) { let t = document.getElementById('bt'); if (!t) { t = document.createElement('div'); t.id = 'bt'; t.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--panel2);border:1px solid var(--line);padding:12px 20px;border-radius:20px;font-size:18px;z-index:99'; document.body.appendChild(t); } t.textContent = msg; t.hidden = false; clearTimeout(t._t); t._t = setTimeout(() => { t.hidden = true; }, 2500); } // ---- Pick your chores: tap your name, enter your PIN once (a child with no PIN goes straight in), then do, take or put back as many chores as you like +const CHEER = ['Great job, NAME! πŸŽ‰', 'Nailed it, NAME! πŸ’ͺ', 'You are on a roll, NAME! πŸ”₯', 'Awesome work, NAME! ⭐', 'Look at you go, NAME! πŸš€', 'Super helper, NAME! πŸ™Œ', 'Way to go, NAME! πŸ‘', 'That is how it is done, NAME! 🌟']; +/** A burst of confetti over the screen (skipped for anyone who asks for less motion). */ +function confetti(big) { + if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return; const box = document.createElement('div'); box.style.cssText = 'position:fixed;inset:0;pointer-events:none;overflow:hidden;z-index:200'; + const bits = big ? ['πŸŽ‰', 'πŸ†', '⭐', '🎊', '✨', '🌟'] : ['πŸŽ‰', '⭐', '✨', '🌟']; for (let i = 0; i < (big ? 40 : 18); i++) { const e = document.createElement('span'); e.textContent = bits[i % bits.length]; e.style.cssText = `position:absolute;top:-30px;left:${Math.random() * 100}%;font-size:${18 + Math.random() * 22}px;animation:cfall ${1.4 + Math.random() * 1.4}s ease-in ${Math.random() * .5}s forwards`; box.appendChild(e); } + document.body.appendChild(box); setTimeout(() => box.remove(), 3500); +} +/** What it takes to reach the goal, in plain words: which chores would do it today, or how far today's chores get them. */ +function goalHint(tier, points, chores) { + const need = tier.points - points, ds = Math.floor((Date.now() - new Date(data.week.from + 'T12:00:00').getTime()) / 864e5), left = 6 - ds; /* days left after today */ + const list = chores.filter(c => c.points > 0).sort((a, b) => b.points - a.points), total = list.reduce((n, c) => n + c.points, 0), tail = left <= 0 ? ' Last day this week!' : left === 1 ? ' One more day after today.' : ' ' + left + ' more days after today.'; + if (!list.length) return need + ' more points to go. New chores tomorrow can get you there.' + tail; + if (total >= need) { const pick = []; let sum = 0; for (const c of list) { if (sum >= need) break; pick.push(c); sum += c.points; } const names = pick.slice(0, 3).map(c => c.title).join(', ') + (pick.length > 3 ? ' and ' + (pick.length - 3) + ' more' : ''); return '⭐ Do ' + names + ' (' + sum + ' pts) and you reach it today!'; } + return 'Do all ' + list.length + ' chore' + (list.length > 1 ? 's' : '') + ' today (+' + total + ' pts) and you will be only ' + (need - total) + ' away. Tomorrow gets you there!' + tail; +} function choreHub(p, pin0) { - if (!p) return; let pin = pin0 || ''; + if (!p) return; let pin = pin0 || '', cheer = ''; const { box, close } = sheet(''), today = ymd(new Date()), head = `
${esc((p.name.trim()[0] || '?').toUpperCase())}

${esc(p.name.split(' ')[0])}'s chores

`; - const act = async (c, action) => { const r = await fetch('/api/board/chore', { method: 'POST', body: JSON.stringify({ uid: p.id, pin, chore: c.chore, action, date: today }) }), j = await r.json().catch(() => ({})); if (!r.ok) { if (p.pin && /pin/i.test(j.error || '')) { pin = ''; return askPin(j.error); } box.querySelector('#e').textContent = j.error || 'That did not work'; return; } await load(); paint(); }; + const act = async (c, action) => { const r = await fetch('/api/board/chore', { method: 'POST', body: JSON.stringify({ uid: p.id, pin, chore: c.chore, action, date: today }) }), j = await r.json().catch(() => ({})); if (!r.ok) { if (p.pin && /pin/i.test(j.error || '')) { pin = ''; return askPin(j.error); } box.querySelector('#e').textContent = j.error || 'That did not work'; return; } const wasPts = ((data.week && data.week.people.find(x => x.id === p.id)) || { points: 0 }).points; await load(); cheer = ''; if (action === 'done') { const w = (data.week && data.week.people.find(x => x.id === p.id)) || { points: wasPts, goal: null }, tier = data.week && data.week.tiers.find(t => t.id === w.goal), hit = tier && wasPts < tier.points && w.points >= tier.points, rest = data.chores.filter(x => x.user === p.id && !x.done).length; + cheer = hit ? 'πŸ† You reached your goal, ' + p.name.split(' ')[0] + '! Tell a parent!' : (CHEER[Math.floor(Math.random() * CHEER.length)].replace('NAME', p.name.split(' ')[0])) + (c.points ? ' +' + c.points + ' pts' : '') + (!rest ? ' ⭐ All your chores are done!' : ''); confetti(hit); } else if (action === 'claim') cheer = 'Good pick! It is yours. πŸ‘'; paint(); }; const paint = () => { const mine = data.chores.filter(c => c.user === p.id), todo = mine.filter(c => !c.done), done = mine.filter(c => c.done), pool = data.chores.filter(c => c.claimable && !c.done), me = (data.week && data.week.people.find(x => x.id === p.id)) || { points: 0 }; const row = (c, btns, cls) => `
${esc(c.title)}${c.points ? ` ${c.points} pts` : ''}${btns}
`, h = t => `

${t}

`; const tier = (data.week ? data.week.tiers : []).find(t => t.id === me.goal), pct = tier ? Math.min(100, Math.round(me.points / tier.points * 100)) : 0, got = tier && me.points >= tier.points; - box.innerHTML = head + (tier ? `
🎯 ${esc(tier.title)}${me.points} / ${tier.points} pts
${got ? 'You did it! πŸŽ‰ Tell a parent' : (tier.points - me.points) + ' more points to go'}
` : `

${me.points} points this week${(data.week ? data.week.tiers : []).some(t => ((data.week.people.find(x => x.id === p.id) || {}).tiers || []).includes(t.id)) ? ' Β· Choose a reward to aim for' : ''}

`) + `
` + box.innerHTML = head + (cheer ? `
${esc(cheer)}
` : '') + (tier ? `
🎯 ${esc(tier.title)}${me.points} / ${tier.points} pts
${got ? 'You did it! πŸŽ‰ Tell a parent' : esc(goalHint(tier, me.points, todo.concat(pool)))}
` : `

${me.points} points this week${(data.week ? data.week.tiers : []).some(t => ((data.week.people.find(x => x.id === p.id) || {}).tiers || []).includes(t.id)) ? ' Β· Choose a reward to aim for' : ''}

`) + `
` + h('To do today') + (todo.map(c => row(c, `${c.claimed ? `` : ''}`)).join('') || '

Nothing left. Nice! πŸŽ‰

') + (pool.length ? h('Up for grabs. Take one!') + pool.map(c => row(c, ``)).join('') : '') + (done.length ? h('Finished') + done.map(c => row(c, ``, 'dn')).join('') : '') diff --git a/public/settings-family.js b/public/settings-family.js index e472939..489cdb6 100644 --- a/public/settings-family.js +++ b/public/settings-family.js @@ -11,8 +11,11 @@ async function famSettings(box) { box.innerHTML = `

You

${can('chat') ? famRow('fs-chat', 'chat', 'Chat', 'Quiet hours for phone alerts' + (admin ? ', and the family announcement' : '')) : ''}
${admin && can('chores') ? `

Chores

${famRow('fs-chores', 'check', 'Chores and weekly rewards', 'Make chores, choose who does them, set the points and the reward tiers')}
` : ''} ${admin ? '

Children

What each child did this week, and Plex.

Loading…
' + famRow('fs-words', 'search', 'Blocked search words', 'A child\'s search only uses Simple English Wikipedia and a built-in list of blocked words. Add your own words here.') + '
' : ''} - ${admin ? '

Family board

Who has a name tile on the shared family screen and shows in its chore progress.

Loading…
' : ''}`; + ${admin ? '

Family board

' + famRow('fs-fname', 'people', 'Family name', 'What the board says: Good morning, ...') + '

Who has a name tile on the shared family screen and shows in its chore progress.

Loading…
' : ''}`; const c = box.querySelector('#fs-chat'); if (c) c.onclick = () => chSettings(); + const fn = box.querySelector('#fs-fname'); if (fn) fn.onclick = async () => { let r; try { r = await jfetch('/api/board/family'); } catch (e) { return toast(e.message); } + const el = openSheet('Family name', 'The family board greets everyone with this. Leave it empty to use the last name most of you share.', `
`); + el.querySelector('#ffx').onclick = closeSheet; el.querySelector('#ff').onsubmit = async e => { e.preventDefault(); try { const o = await jfetch('/api/board/family', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#fnn').value }) }); closeSheet(); toast('The board now says: Good morning, ' + o.shown); } catch (x) { toast(x.message); } }; }; const k = box.querySelector('#fs-chores'); if (k) k.onclick = famManageChores; if (typeof devSettings === 'function') devSettings(box.querySelector('#fs-dev')); if (admin) { famRequests(box.querySelector('#fs-req')); famKids(box.querySelector('#fs-kids')); const w = box.querySelector('#fs-words'); if (w) w.onclick = famWords; } diff --git a/tools/board-test.js b/tools/board-test.js index 3ae4a07..9add9e1 100644 --- a/tools/board-test.js +++ b/tools/board-test.js @@ -26,6 +26,7 @@ const hub = http.createServer((req, res) => req.url.startsWith('/api/chores') ? 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 fam = 'hub_session=' + session(ann).slice(12); assert.equal(typeof (await call('/api/board?day=' + today, {}, dev)).json.familyName, 'string'); assert.equal((await post('/api/board/family', { name: 'Emmert Family' }, dev)).status, 403, 'only a signed-in admin sets the family name'); assert.equal((await post('/api/board/family', { name: 'Emmert Family' }, fam)).status, 200); assert.equal((await call('/api/board?day=' + today, {}, dev)).json.familyName, 'Emmert Family', 'the board greets the family by name'); 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');