Files
hub/public/chores-view.js
T
nmemmertandClaude Sonnet 5.5 0383da87f5
build-container / build (push) Successful in 4m6s
Chores, Family page, genre search, child accounts, wider Search and Notifications center
- Chores tab with repeats, points and a week/month board
- Family page: calendar, chores, shopping, notes, chat and tasks on one start page
- Genre search in the Store, Free Books and LibriVox
- Child accounts: safe content, daily limit and bedtime, ask a parent
- Search now covers notes, chat, contacts, chores, recipes and your books
- Notifications center: bell with a per-person list of chat, reminders and announcements
- Roadmap, README and tests updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 11:48:55 -04:00

63 lines
11 KiB
JavaScript

'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 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');
const crShift = (s, n) => { const d = new Date(s + 'T12:00:00'); d.setDate(d.getDate() + n); return crYmd(d); };
const crDayLabel = s => s === crYmd(new Date()) ? 'Today' : s === crShift(crYmd(new Date()), -1) ? 'Yesterday' : s === crShift(crYmd(new Date()), 1) ? 'Tomorrow' : new Date(s + 'T12:00:00').toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' });
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'];
async function viewChores() {
const v = $('#view'); cr.day = cr.day || crYmd(new Date());
v.innerHTML = '<div class="wrap"><h1>Chores</h1><p class="empty">Loading…</p></div>';
try { [cr.data, cr.sum] = await Promise.all([crApi('/?date=' + cr.day), crApi('/summary?range=' + cr.range + '&date=' + cr.day)]); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Chores</h1><p class="err">${esc(e.message)}</p></div>`; 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 => `<label class="critem ${i.done ? 'done' : ''}"><input type="checkbox" data-c="${esc(i.chore)}" data-u="${esc(i.user || '')}" ${i.done ? 'checked' : ''} ${i.canDo ? '' : 'disabled'}><span class="grow"><b>${esc(i.title)}</b>${i.notes ? `<small>${esc(i.notes)}</small>` : ''}${i.done ? `<small>Done by ${esc(i.done.byName)}</small>` : i.overdue ? '<small style="color:var(--danger)">Overdue</small>' : ''}</span>${i.points ? `<em class="crpts">${i.points} pts</em>` : ''}</label>`;
const person = p => { const l = d.board.filter(i => i.user === p.id), done = l.filter(i => i.done).length; return `<div class="crperson"><div class="crph"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><b>${esc(p.name)}</b><small>${l.length ? done + ' of ' + l.length : 'nothing today'}</small></div>${l.length ? `<div class="crbar"><i style="width:${Math.round(done / l.length * 100)}%"></i></div>` : ''}<div>${l.map(item).join('')}</div></div>`; };
const max = Math.max(1, ...cr.sum.people.map(p => p.points));
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Chores</h1><span class="pspace grow"></span>${d.me.admin ? `<button class="btn ghost" id="crman">${icon('cog')}Manage</button>` : ''}</div>
<div class="bar" style="justify-content:flex-start;margin:0 0 8px"><button class="ibtn" id="crprev" aria-label="Previous day">${icon('back')}</button><b style="min-width:150px;text-align:center">${esc(crDayLabel(cr.day))}</b><button class="ibtn" id="crnext" aria-label="Next day" style="transform:scaleX(-1)">${icon('back')}</button>${cr.day !== crYmd(new Date()) ? '<button class="btn ghost" id="crtoday">Today</button>' : ''}</div>
<h2>Mine</h2><div class="crcard">${mine.map(item).join('') || '<p class="empty" style="margin:8px 0">Nothing for you ' + (cr.day === crYmd(new Date()) ? 'today' : 'on this day') + '. 🎉</p>'}</div>
${anyone.length ? `<h2>Anyone can do</h2><div class="crcard">${anyone.map(item).join('')}</div>` : ''}
${others.length ? `<h2>The family</h2><div class="crgrid">${others.map(person).join('')}</div>` : ''}
<div class="ptop"><h2 style="margin:18px 0 6px">Points</h2><span class="pspace grow"></span><div class="seg" style="margin:0"><a href="#" data-r="week" class="${cr.range === 'week' ? 'on' : ''}">Week</a><a href="#" data-r="month" class="${cr.range === 'month' ? 'on' : ''}">Month</a></div></div>
<div class="crcard">${cr.sum.people.map((p, i) => `<div class="crrank"><span class="num">${i + 1}</span><span class="grow"><b>${esc(p.name)}</b><div class="statbar"><i style="width:${Math.round(p.points / max * 100)}%"></i></div></span><small>${p.points} pts · ${p.count} done</small></div>`).join('') || '<p class="empty">No chores yet.</p>'}</div></div>`;
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(); });
if ($('#crman')) $('#crman').onclick = crManage;
}
// ---- making and changing chores (admin)
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', `<div class="list" style="max-height:52dvh;overflow:auto">${d.chores.map(c => `<button class="row" data-e="${esc(c.id)}"><span class="grow"><b>${esc(c.title)}${c.active ? '' : ' (paused)'}</b><small style="white-space:normal">${esc(when(c))} · ${esc(who(c))}${c.points ? ' · ' + c.points + ' pts' : ''}</small></span>${icon('chev', 'chev')}</button>`).join('') || '<p class="empty">No chores yet.</p>'}</div>
<div class="bar"><button class="btn" id="crnew" style="width:100%;justify-content:center">${icon('plus')}New chore</button></div>`);
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 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', '', `<form id="cref"><label for="crt">What needs doing</label><input type="text" id="crt" value="${esc(x.title)}" maxlength="120" required autocomplete="off" placeholder="Empty the dishwasher">
<label for="crn">Notes (optional)</label><input type="text" id="crn" value="${esc(x.notes)}" maxlength="400" autocomplete="off">
<label for="crr">How often</label><select id="crr">${Object.entries(CR_REPEAT).map(([k, l]) => `<option value="${k}"${x.repeat === k ? ' selected' : ''}>${l}</option>`).join('')}</select>
<div id="crd" hidden><label>On which days (none: any day that week)</label><div class="seg">${CR_DAYS.map((n, i) => `<a href="#" data-d="${i}" class="${(x.days || []).includes(i) ? 'on' : ''}">${n}</a>`).join('')}</div></div>
<div id="crm" hidden><label for="crdom">Day of the month</label><input type="number" id="crdom" min="1" max="31" value="${x.dom || 1}"></div><div id="cro" hidden><label for="crdt">On</label><input type="date" id="crdt" value="${esc(x.date || '')}"></div>
<label>Who does it</label><div class="list"><label class="row" style="cursor:pointer"><input type="radio" name="crw" value="anyone"${x.assign === 'anyone' ? ' checked' : ''}><span class="grow"><b>Anyone</b><small>Whoever gets to it first</small></span></label><label class="row" style="cursor:pointer"><input type="radio" name="crw" value="people"${x.assign !== 'anyone' ? ' checked' : ''}><span class="grow"><b>These people</b><small>Each one has their own to do</small></span></label></div>
<div class="list" id="crp">${d.people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}"${chosen.has(p.id) ? ' checked' : ''}><span class="grow"><b>${esc(p.name)}</b></span></label>`).join('')}</div>
<label for="crpt">Points for doing it</label><input type="number" id="crpt" min="0" max="50" value="${x.points || 0}">
<label class="row" style="cursor:pointer"><span class="grow"><b>Active</b><small>Turn off to pause it</small></span><input type="checkbox" id="cra"${x.active ? ' checked' : ''}></label><div class="err" id="cre"></div>
<div class="bar">${c ? '<button type="button" class="btn danger" id="crdel">Delete</button>' : ''}<button type="button" class="btn ghost" id="crx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
const sync = () => { const r = el.querySelector('#crr').value; el.querySelector('#crd').hidden = r !== 'weekly'; el.querySelector('#crm').hidden = r !== 'monthly'; el.querySelector('#cro').hidden = r !== 'once'; el.querySelector('#crp').hidden = el.querySelector('[name=crw]:checked').value !== 'people'; };
el.querySelector('#crr').onchange = sync; el.querySelectorAll('[name=crw]').forEach(r => r.onchange = sync); sync(); el.querySelectorAll('#crd [data-d]').forEach(a => a.onclick = e => { e.preventDefault(); a.classList.toggle('on'); });
el.querySelector('#crx').onclick = closeSheet;
if (c) el.querySelector('#crdel').onclick = () => { closeSheet(); confirmSheet('Delete this chore?', c.title + ' and its history.', 'Delete', async () => { try { await crApi('/' + c.id, { method: 'DELETE' }); toast('Deleted'); viewChores(); } catch (e) { toast(e.message); } }); };
el.querySelector('#cref').onsubmit = async e => { e.preventDefault(); const people = [...el.querySelectorAll('[data-p]:checked')].map(i => i.dataset.p);
try { await crPost('/', { id: c && c.id, title: el.querySelector('#crt').value, notes: el.querySelector('#crn').value, repeat: el.querySelector('#crr').value, days: [...el.querySelectorAll('#crd .on')].map(a => +a.dataset.d), dom: +el.querySelector('#crdom').value, date: el.querySelector('#crdt').value, assign: el.querySelector('[name=crw]:checked').value === 'anyone' ? 'anyone' : people, points: +el.querySelector('#crpt').value, active: el.querySelector('#cra').checked }); closeSheet(); toast('Saved'); viewChores(); }
catch (x) { el.querySelector('#cre').textContent = x.message; } };
}