Settings: a Family tab gathering chat, PIN, chores and rewards, family board people and shared screens
build-container / build (push) Successful in 5m27s

- New Family tab in Settings; each row opens the existing sheet
- Admin switches for who appears on the family board
- Shared-screen controls moved here from Security
- Chore manager can be reopened from Settings after a save

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 14:13:23 -04:00
1 parent 3c721ef6a5
commit 900e900e53
5 files changed
+33 -8

No files matched your search

+6 -4
View File
@@ -19,6 +19,8 @@ function crWeekPerson(p) {
${p.earned.map(id => { const t = w.tiers.find(x => x.id === id), given = p.given.includes(id); return `<div class="crearn"><span>🎁</span><span class="grow"><b>${esc(t.title)}</b><small>${given ? 'Given' : 'Earned, not given yet'}</small></span>${admin ? `<button class="btn ${given ? 'ghost' : ''}" data-give="${esc(p.id)}|${esc(id)}" ${given ? 'data-undo="1"' : ''}>${given ? 'Undo' : 'Mark given'}</button>` : ''}</div>`; }).join('')} ${p.earned.map(id => { const t = w.tiers.find(x => x.id === id), given = p.given.includes(id); return `<div class="crearn"><span>🎁</span><span class="grow"><b>${esc(t.title)}</b><small>${given ? 'Given' : 'Earned, not given yet'}</small></span>${admin ? `<button class="btn ${given ? 'ghost' : ''}" data-give="${esc(p.id)}|${esc(id)}" ${given ? 'data-undo="1"' : ''}>${given ? 'Undo' : 'Mark given'}</button>` : ''}</div>`; }).join('')}
${p.done.length ? `<details class="crdet"><summary>Chores done (${p.done.length})</summary>${p.done.map(c => `<div class="crrank" style="border:0;padding:3px 0"><span class="grow">${esc(c.title)}<small style="display:block">${esc(new Date(c.date + 'T12:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }))}</small></span><small>${c.pts ? c.pts + ' pts' : ''}</small></div>`).join('')}</details>` : ''}</div>`; ${p.done.length ? `<details class="crdet"><summary>Chores done (${p.done.length})</summary>${p.done.map(c => `<div class="crrank" style="border:0;padding:3px 0"><span class="grow">${esc(c.title)}<small style="display:block">${esc(new Date(c.date + 'T12:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }))}</small></span><small>${c.pts ? c.pts + ' pts' : ''}</small></div>`).join('')}</details>` : ''}</div>`;
} }
/** 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() { async function viewChores() {
const v = $('#view'); cr.day = cr.day || crYmd(new Date()); const v = $('#view'); cr.day = cr.day || crYmd(new Date());
v.innerHTML = '<div class="wrap"><h1>Chores</h1><p class="empty">Loading…</p></div>'; v.innerHTML = '<div class="wrap"><h1>Chores</h1><p class="empty">Loading…</p></div>';
@@ -62,8 +64,8 @@ function crReward(r) {
<label>Who is it for?</label><div class="list"><label class="row" style="cursor:pointer"><input type="checkbox" id="rwall"${!r || !Array.isArray(r.for) ? ' checked' : ''}><span class="grow"><b>Everyone</b></span></label></div><div class="list" id="rwpp">${cr.data.people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}"${r && Array.isArray(r.for) && r.for.includes(p.id) ? ' checked' : ''}><span class="grow"><b>${esc(p.name)}</b></span></label>`).join('')}</div><div class="err" id="rwe"></div> <label>Who is it for?</label><div class="list"><label class="row" style="cursor:pointer"><input type="checkbox" id="rwall"${!r || !Array.isArray(r.for) ? ' checked' : ''}><span class="grow"><b>Everyone</b></span></label></div><div class="list" id="rwpp">${cr.data.people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}"${r && Array.isArray(r.for) && r.for.includes(p.id) ? ' checked' : ''}><span class="grow"><b>${esc(p.name)}</b></span></label>`).join('')}</div><div class="err" id="rwe"></div>
<div class="bar">${r ? '<button type="button" class="btn danger" id="rwdel">Delete</button>' : ''}<button type="button" class="btn ghost" id="rwx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); <div class="bar">${r ? '<button type="button" class="btn danger" id="rwdel">Delete</button>' : ''}<button type="button" class="btn ghost" id="rwx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
const rsync = () => { el.querySelector('#rwpp').hidden = el.querySelector('#rwall').checked; }; el.querySelector('#rwall').onchange = rsync; rsync(); 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('#rwx').onclick = closeSheet; if (r) el.querySelector('#rwdel').onclick = async () => { try { await crPost('/reward', { id: r.id, remove: true }); closeSheet(); crRefresh(); } 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; } }; 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(); crRefresh(); } catch (x) { el.querySelector('#rwe').textContent = x.message; } };
} }
function crEdit(c) { 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 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);
@@ -80,8 +82,8 @@ function crEdit(c) {
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'; }; 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('#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; 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); } }); }; 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'); crRefresh(); } 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); 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(); } 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'); crRefresh(); }
catch (x) { el.querySelector('#cre').textContent = x.message; } }; catch (x) { el.querySelector('#cre').textContent = x.message; } };
} }
+1
View File
@@ -103,6 +103,7 @@
<script src="/keys.js"></script> <script src="/keys.js"></script>
<script src="/inbox-view.js"></script> <script src="/inbox-view.js"></script>
<script src="/device-ui.js"></script> <script src="/device-ui.js"></script>
<script src="/settings-family.js"></script>
<script src="/boot.js"></script> <script src="/boot.js"></script>
<script src="/shell.js"></script> <script src="/shell.js"></script>
</body> </body>
+22
View File
@@ -0,0 +1,22 @@
'use strict';
// ---- Settings > Family: the family's settings in one place (chat, your PIN, chores and rewards, who is on the family board, shared screens).
// Each row opens the same sheet that used to live inside that part of the hub.
const famRow = (id, ic, title, sub) => `<button class="row" id="${id}"><span class="ic" style="background:#6a7280">${icon(ic)}</span><span class="grow"><b>${esc(title)}</b><small style="white-space:normal">${esc(sub)}</small></span>${icon('chev', 'chev')}</button>`;
async function famManageChores() {
try { cr.data = await crApi('/?date=' + crYmd(new Date())); } catch (e) { return toast(e.message); }
cr.afterEdit = famManageChores; crManage();
}
async function famSettings(box) {
if (!box) return; const admin = ME && ME.role === 'admin', can = t => typeof allowed === 'function' && allowed(t);
box.innerHTML = `<h2>You</h2><div class="list">${can('chat') ? famRow('fs-chat', 'chat', 'Chat', 'Quiet hours for phone alerts' + (admin ? ', and the family announcement' : '')) : ''}</div><div id="fs-dev"></div>
${admin && can('chores') ? `<h2>Chores</h2><div class="list">${famRow('fs-chores', 'check', 'Chores and weekly rewards', 'Make chores, choose who does them, set the points and the reward tiers')}</div>` : ''}
${admin ? '<h2>Family board</h2><p class="sub" style="margin:0 0 8px">Who has a name tile on the shared family screen and shows in its chore progress.</p><div class="list" id="fs-board"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>' : ''}`;
const c = box.querySelector('#fs-chat'); if (c) c.onclick = () => chSettings();
const k = box.querySelector('#fs-chores'); if (k) k.onclick = famManageChores;
if (typeof devSettings === 'function') devSettings(box.querySelector('#fs-dev'));
if (admin) try {
const r = await jfetch('/api/users'), b = box.querySelector('#fs-board'); if (!b) return;
b.innerHTML = r.users.filter(u => !u.disabled).map(u => `<label class="row" style="cursor:pointer"><span class="ic" style="background:${u.role === 'admin' ? '#4f7cff' : '#6a7280'}">${esc(u.name.slice(0, 1).toUpperCase())}</span><span class="grow"><b>${esc(u.name)}</b>${u.kid ? '<small>Child</small>' : ''}</span><input type="checkbox" data-u="${esc(u.id)}" ${u.board === false ? '' : 'checked'}></label>`).join('');
b.querySelectorAll('input[data-u]').forEach(i => i.onchange = async () => { try { await jfetch('/api/users/' + encodeURIComponent(i.dataset.u), { method: 'PUT', body: JSON.stringify({ board: i.checked }) }); } catch (e) { toast(e.message); i.checked = !i.checked; } });
} catch (e) { const b = box.querySelector('#fs-board'); if (b) b.innerHTML = `<div class="row"><span class="grow"><small>${esc(e.message)}</small></span></div>`; }
}
+3 -3
View File
@@ -78,7 +78,7 @@ function viewSettings() {
const isAdmin = ME && ME.role === 'admin'; const isAdmin = ME && ME.role === 'admin';
const tabNames = ME ? TABS.filter(t => ME.tabs.includes(t[0]) && !['home', 'settings'].includes(t[0])).map(t => t[1]).join(', ') : ''; const tabNames = ME ? TABS.filter(t => ME.tabs.includes(t[0]) && !['home', 'settings'].includes(t[0])).map(t => t[1]).join(', ') : '';
$('#view').innerHTML = `<div class="wrap"><h1>Settings</h1> $('#view').innerHTML = `<div class="wrap"><h1>Settings</h1>
<div class="sgrp" id="settabs" role="tablist" aria-label="Settings sections">${[['general', 'General'], ['connections', 'Connections'], ['security', 'Security'], ...(isAdmin ? [['admin', 'Admin']] : [])].map(([k, l]) => `<a href="#" role="tab" data-g="${k}">${l}</a>`).join('')}</div> <div class="sgrp" id="settabs" role="tablist" aria-label="Settings sections">${[['general', 'General'], ['connections', 'Connections'], ['family', 'Family'], ['security', 'Security'], ...(isAdmin ? [['admin', 'Admin']] : [])].map(([k, l]) => `<a href="#" role="tab" data-g="${k}">${l}</a>`).join('')}</div>
<div class="sgroup" data-g="general">${ME ? `<h2>Signed in as</h2><div class="list"><div class="row"><span class="grow"><b>${esc(ME.name)}</b><small style="white-space:normal">${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}</small></span></div></div>` : ''} <div class="sgroup" data-g="general">${ME ? `<h2>Signed in as</h2><div class="list"><div class="row"><span class="grow"><b>${esc(ME.name)}</b><small style="white-space:normal">${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}</small></span></div></div>` : ''}
<div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p> <div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label> <div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
@@ -98,9 +98,9 @@ function viewSettings() {
<label for="ab-p">Password</label> <label for="ab-p">Password</label>
<input type="password" id="ab-p" autocomplete="new-password" value="${esc(abCreds().p)}"></div> <input type="password" id="ab-p" autocomplete="new-password" value="${esc(abCreds().p)}"></div>
</div></div> </div></div>
<div class="sgroup" data-g="family"><div id="famset"></div></div>
<div class="sgroup" data-g="security"><h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div> <div class="sgroup" data-g="security"><h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div> <div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
<div id="famdev"></div>
<h2>Recovery codes</h2><div class="list"><div class="row"><span class="grow"><b id="rcn">Checking…</b><small style="white-space:normal">One-time codes that let you add a new passkey if you lose every device. Keep them somewhere safe (a password manager or on paper).</small></span><button class="btn ghost" id="rcgen">Make codes</button></div></div> <h2>Recovery codes</h2><div class="list"><div class="row"><span class="grow"><b id="rcn">Checking…</b><small style="white-space:normal">One-time codes that let you add a new passkey if you lose every device. Keep them somewhere safe (a password manager or on paper).</small></span><button class="btn ghost" id="rcgen">Make codes</button></div></div>
</div> </div>
<div class="sgroup" data-g="admin">${isAdmin ? `<h2>People</h2><div class="list" id="people"><div class="row"><span class="grow"><small>Loading…</small></span></div></div> <div class="sgroup" data-g="admin">${isAdmin ? `<h2>People</h2><div class="list" id="people"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
@@ -196,7 +196,7 @@ function viewSettings() {
const el = $('#audit'); if (!el) return; const el = $('#audit'); if (!el) return;
el.innerHTML = events.length ? events.slice(0, 40).map(e => `<div class="row"><span class="grow"><b>${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}</b><small style="white-space:normal">${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}</small></span></div>`).join('') : '<div class="row"><span class="grow"><small>Nothing yet</small></span></div>'; el.innerHTML = events.length ? events.slice(0, 40).map(e => `<div class="row"><span class="grow"><b>${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}</b><small style="white-space:normal">${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}</small></span></div>`).join('') : '<div class="row"><span class="grow"><small>Nothing yet</small></span></div>';
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; }); }).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
if (typeof devSettings === 'function') devSettings($('#famdev')); if (typeof famSettings === 'function') famSettings($('#famset'));
$('#invpk').onclick = async () => { $('#invpk').onclick = async () => {
try { try {
const { code, minutes } = await authPost('/api/auth/invite'); const { code, minutes } = await authPost('/api/auth/invite');
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network. // App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v78'; const CACHE = 'hub-v79';
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder 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']; const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];