Chores: claim from the pool; admin picks who appears on the family board
build-container / build (push) Successful in 4m21s

- "Anyone" chores get Take it / Put back; a claimed chore is that person's for the day
- Per-person "Show on the family board" switch controls board tiles and family chore progress
- Tests and README updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 13:23:59 -04:00
1 parent eef1adaa73
commit 870c7129b9
11 files changed
+39 -18

No files matched your search

+2 -2
View File
@@ -184,7 +184,7 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions
**Notes** (turn it on per person): notes and checklists that are private, shared with the whole family, or shared with chosen people. Anyone it is shared with can edit it. Checklist items change one at a time (tick, add, edit, remove), so two people ticking the same list never overwrite each other, ticked items say who ticked them, and an open list follows other people's changes every few seconds. Text edits are checked against a revision, so a stale edit is refused instead of overwriting someone else's. Pin, colour, archive, search and filters (lists, notes, mine, shared, archived), ready-made lists (grocery, packing, weekend to-do), *Share to Chat*, and *Send the open items to Tasks*. Only the person who made a note (or an admin) changes who it is shared with or deletes it. **Notes** (turn it on per person): notes and checklists that are private, shared with the whole family, or shared with chosen people. Anyone it is shared with can edit it. Checklist items change one at a time (tick, add, edit, remove), so two people ticking the same list never overwrite each other, ticked items say who ticked them, and an open list follows other people's changes every few seconds. Text edits are checked against a revision, so a stale edit is refused instead of overwriting someone else's. Pin, colour, archive, search and filters (lists, notes, mine, shared, archived), ready-made lists (grocery, packing, weekend to-do), *Share to Chat*, and *Send the open items to Tasks*. Only the person who made a note (or an admin) changes who it is shared with or deletes it.
**Chores** (turn it on per person): jobs that repeat daily, on weekdays, on chosen days of the week, weekly, monthly or happen once, given to one or more people or to anyone. Each person ticks off their own (an admin can tick for someone), finished chores earn points, and the board adds up the week and month. Only an admin adds or changes chores. Kept in `data/chores.json`. **Chores** (turn it on per person): jobs that repeat daily, on weekdays, on chosen days of the week, weekly, monthly or happen once, given to one or more people or to anyone. Each person ticks off their own (an admin can tick for someone), finished chores earn points, and the board adds up the week and month. Only an admin adds or changes chores. A chore given to *anyone* is a pool: a person taps **Take it** to claim it for the day (it moves under their name, and nobody else can tick it; **Put back** returns it to the pool). Kept in `data/chores.json`.
**Family** (the page every account has next to Home): today and tomorrow's calendar, your chores and everyone's progress, the shopping list, shared notes and lists, recent chats and the tasks due this week. Each card only shows if the person has that part of the hub. **Family** (the page every account has next to Home): today and tomorrow's calendar, your chores and everyone's progress, the shopping list, shared notes and lists, recent chats and the tasks due this week. Each card only shows if the person has that part of the hub.
@@ -196,7 +196,7 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions
**Notifications** (the bell on Home and Family): a list of what you have not seen yet: chat messages (one line per conversation, with a count), reminders for tasks, events and mail, and family announcements. Tap one to open it; mark all read or clear. Kept per person in `inbox.json`. **Notifications** (the bell on Home and Family): a list of what you have not seen yet: chat messages (one line per conversation, with a count), reminders for tasks, events and mail, and family announcements. Tap one to open it; mark all read or clear. Kept per person in `inbox.json`.
**Family devices** (Settings, under Passkeys): for a kitchen tablet or the family computer. An admin turns on *Make this a family device* while signed in on it; from then on it opens on the **family board** (announcement, today's family calendar, chores with progress, shopping list, family tasks and notes) and a row of name tiles, children marked **KID**. Tap your name: a child signs in with one tap, everyone else enters a PIN they set in Settings (five wrong tries lock that person for five minutes; there is also a passkey link). A label with your name and **Switch user** stays on screen, and the device goes back to the board after five idle minutes (music playing keeps it signed in). Sessions on a family device last 30 minutes at a time, not months. Turn it off from the same place. The Family calendar and Family task list are shared by everyone and appear on the board. **Family devices** (Settings, under Passkeys): for a kitchen tablet or the family computer. An admin turns on *Make this a family device* while signed in on it; from then on it opens on the **family board** (announcement, today's family calendar, chores with progress, shopping list, family tasks and notes) and a row of name tiles, children marked **KID**. Tap your name: a child signs in with one tap, everyone else enters a PIN they set in Settings (five wrong tries lock that person for five minutes; there is also a passkey link). A label with your name and **Switch user** stays on screen, and the device goes back to the board after five idle minutes (music playing keeps it signed in). Sessions on a family device last 30 minutes at a time, not months. Turn it off from the same place. The admin picks who appears on the board (a **Show on the family board** switch in each person's settings; people left off do not get a tile or show in family chore progress). The Family calendar and Family task list are shared by everyone and appear on the board.
On a tablet or with the menu collapsed, the menu rail shows icons only (the name appears on hover) when the window is not tall enough for a label under every icon. On a tablet or with the menu collapsed, the menu rail shows icons only (the name appears on hover) when the window is not tall enough for a label under every icon.
+1 -1
View File
@@ -26,7 +26,7 @@ async function handle(req, res, me) {
const b = JSON.parse(await readBody(req) || '{}'); const b = JSON.parse(await readBody(req) || '{}');
if (u.id === me.id && b.disabled) return json(res, 400, { error: 'You cannot turn off your own account' }); if (u.id === me.id && b.disabled) return json(res, 400, { error: 'You cannot turn off your own account' });
audit.log('person changed', { who: me.name, detail: u.name + (b.disabled !== undefined ? (b.disabled ? ' (turned off)' : ' (turned on)') : '') + (b.signOutEverywhere ? ' (signed out everywhere)' : '') + (b.tabs ? ' (tabs)' : '') + (b.role ? ' (role)' : '') }, req); audit.log('person changed', { who: me.name, detail: u.name + (b.disabled !== undefined ? (b.disabled ? ' (turned off)' : ' (turned on)') : '') + (b.signOutEverywhere ? ' (signed out everywhere)' : '') + (b.tabs ? ' (tabs)' : '') + (b.role ? ' (role)' : '') }, req);
users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere, kid: b.kid }); users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere, kid: b.kid, board: b.board });
return json(res, 200, { user: row(users.get(id)) }); return json(res, 200, { user: row(users.get(id)) });
} }
if (!action && req.method === 'DELETE') { if (!action && req.method === 'DELETE') {
+4 -3
View File
@@ -25,12 +25,13 @@ function checkPin(uid, pin) {
const ymdOk = s => /^\d{4}-\d{2}-\d{2}$/.test(s || ''); const ymdOk = s => /^\d{4}-\d{2}-\d{2}$/.test(s || '');
const isKid = u => !!(u.kid && u.kid.on); const isKid = u => !!(u.kid && u.kid.on);
const palette = ['#e0742b', '#2f9e6e', '#4f7cff', '#8a5cf0', '#d9453a', '#2aa6a0', '#e5a00d']; const palette = ['#e0742b', '#2f9e6e', '#4f7cff', '#8a5cf0', '#d9453a', '#2aa6a0', '#e5a00d'];
const people = () => users.all().filter(u => !u.disabled).map((u, i) => ({ id: u.id, name: u.name, kid: isKid(u), pin: hasPin(u.id), color: palette[i % palette.length] })); const onBoard = u => !u.disabled && u.board !== false; // the admin picks who is on the family board
const people = () => users.all().filter(onBoard).map((u, i) => ({ id: u.id, name: u.name, kid: isKid(u), pin: hasPin(u.id), color: palette[i % palette.length] }));
async function board(day) { 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; 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 {} 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).map(i => ({ title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done })), 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 => ({ title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done })), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
} }
async function handle(req, res) { async function handle(req, res) {
@@ -39,7 +40,7 @@ async function handle(req, res) {
if (p === '/api/board' && req.method === 'GET') { if (!dev) throw fail(403, 'This is not a family device'); const day = u.searchParams.get('day'); return json(res, 200, await board(ymdOk(day) ? day : new Date().toISOString().slice(0, 10))); } if (p === '/api/board' && req.method === 'GET') { if (!dev) throw fail(403, 'This is not a family device'); const day = u.searchParams.get('day'); return json(res, 200, await board(ymdOk(day) ? day : new Date().toISOString().slice(0, 10))); }
if (p === '/api/board/switch' && req.method === 'POST') { if (p === '/api/board/switch' && req.method === 'POST') {
if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), t = users.get(String(b.uid)); if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), t = users.get(String(b.uid));
if (!t || t.disabled) throw fail(404, 'Person not found'); if (!t || !onBoard(t)) throw fail(404, 'Person not found');
if (!isKid(t) || hasPin(t.id)) { // a child with no PIN just taps their name; everyone else needs theirs if (!isKid(t) || hasPin(t.id)) { // a child with no PIN just taps their name; everyone else needs theirs
if (!hasPin(t.id)) throw fail(403, 'Set a PIN first (Settings, on your own device), or sign in with your passkey'); if (!hasPin(t.id)) throw fail(403, 'Set a PIN first (Settings, on your own device), or sign in with your passkey');
if (!checkPin(t.id, b.pin)) { audit.log('family device: wrong PIN', { who: t.name }, req); throw fail(401, 'That PIN is not right'); } if (!checkPin(t.id, b.pin)) { audit.log('family device: wrong PIN', { who: t.name }, req); throw fail(401, 'That PIN is not right'); }
+17 -6
View File
@@ -18,11 +18,11 @@ const addDays = (s, n) => { const d = utc(s); d.setUTCDate(d.getUTCDate() + n);
const weekday = s => utc(s).getUTCDay(); // 0 = Sunday const weekday = s => utc(s).getUTCDay(); // 0 = Sunday
const monday = s => addDays(s, -((weekday(s) + 6) % 7)); const monday = s => addDays(s, -((weekday(s) + 6) % 7));
let cache = null; 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 : [] }; } catch { return { chores: [], log: [] }; } })()); 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 : [] }; } catch { return { chores: [], log: [], claims: [] }; } })());
const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); }; 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 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'; }; const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chores')).map(u => ({ id: u.id, name: u.name })); const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chores')).map(u => ({ id: u.id, name: u.name, board: u.board !== false }));
/** The period a chore is due in on this day (the key its completion is filed under), or null when it is not due. Weekly chores with no chosen days, monthly and one-off chores stay due until done. */ /** The period a chore is due in on this day (the key its completion is filed under), or null when it is not due. Weekly chores with no chosen days, monthly and one-off chores stay due until done. */
function dueKey(c, day) { function dueKey(c, day) {
@@ -42,11 +42,12 @@ function board(day) {
const out = []; const out = [];
for (const c of db().chores) { for (const c of db().chores) {
const key = dueKey(c, day); if (!key) continue; const who = assignees(c); const key = dueKey(c, day); if (!key) continue; const who = assignees(c);
const targets = c.assign === 'anyone' || !who.length ? [null] : who; const anyone = c.assign === 'anyone' || !who.length, claim = anyone ? db().claims.find(x => x.chore === c.id && x.key === key && live(x.user)) : null; // (someone has taken this one from the pool)
const targets = anyone ? [claim ? claim.user : null] : who;
for (const u of targets) { for (const u of targets) {
const done = db().log.find(l => l.chore === c.id && l.key === key && (u === null || l.user === u)); const done = db().log.find(l => l.chore === c.id && l.key === key && (anyone || l.user === u));
if (c.repeat === 'once' && !done && c.date && c.date > day) continue; // (a one-off is shown from its day on) if (c.repeat === 'once' && !done && c.date && c.date > day) continue; // (a one-off is shown from its day on)
out.push({ chore: c.id, key, title: c.title, notes: c.notes, points: c.points, repeat: c.repeat, user: u, userName: u ? nameOf(u) : 'Anyone', done: done ? { by: done.by, byName: nameOf(done.by), at: done.at, pts: done.pts } : null, overdue: !done && c.repeat === 'once' && c.date < day }); out.push({ chore: c.id, key, title: c.title, notes: c.notes, points: c.points, repeat: c.repeat, user: u, userName: u ? nameOf(u) : 'Anyone', claimed: !!claim, claimable: anyone && !claim, done: done ? { by: done.by, byName: nameOf(done.by), at: done.at, pts: done.pts } : null, overdue: !done && c.repeat === 'once' && c.date < day });
} }
} }
return out.sort((a, b) => (a.done ? 1 : 0) - (b.done ? 1 : 0) || a.title.localeCompare(b.title)); return out.sort((a, b) => (a.done ? 1 : 0) - (b.done ? 1 : 0) || a.title.localeCompare(b.title));
@@ -78,18 +79,28 @@ async function handle(req, res) {
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'); 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); 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);
} }
if ((m = /^\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { if (!admin) throw fail(403, 'Only an admin can delete chores'); const d = db(); d.chores = d.chores.filter(x => x.id !== m[1]); d.log = d.log.filter(l => l.chore !== m[1]); save(); return json(res, 200, { ok: true }); } if ((m = /^\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { if (!admin) throw fail(403, 'Only an admin can delete chores'); const d = db(); d.chores = d.chores.filter(x => x.id !== m[1]); d.log = d.log.filter(l => l.chore !== m[1]); d.claims = d.claims.filter(x => x.chore !== m[1]); save(); return json(res, 200, { ok: true }); }
if (p === '/done' && req.method === 'POST') { // tick a chore off (or take the tick back) if (p === '/done' && req.method === 'POST') { // tick a chore off (or take the tick back)
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = db().chores.find(x => x.id === String(b.chore)), date = b.date || ymd(new Date()); if (!c) throw fail(404, 'Chore not found'); if (!ymdOk(date)) throw fail(400, 'Bad date'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = db().chores.find(x => x.id === String(b.chore)), date = b.date || ymd(new Date()); if (!c) throw fail(404, 'Chore not found'); if (!ymdOk(date)) throw fail(400, 'Bad date');
const today = ymd(new Date()); if (date > addDays(today, 1) || date < addDays(today, -7)) throw fail(400, 'That day is out of range'); const today = ymd(new Date()); if (date > addDays(today, 1) || date < addDays(today, -7)) throw fail(400, 'That day is out of range');
const key = dueKey(c, date); if (!key) throw fail(400, 'That chore is not due on that day'); const key = dueKey(c, date); if (!key) throw fail(400, 'That chore is not due on that day');
const who = c.assign === 'anyone' ? null : String(b.user || me.id); if (who && !assignees(c).includes(who)) throw fail(400, 'That chore is not given to that person'); const who = c.assign === 'anyone' ? null : String(b.user || me.id); if (who && !assignees(c).includes(who)) throw fail(400, 'That chore is not given to that person');
const taken = !who && db().claims.find(x => x.chore === c.id && x.key === key && live(x.user)); if (taken && taken.user !== me.id && !admin) throw fail(403, nameOf(taken.user) + ' took this one');
if (who && who !== me.id && !admin) throw fail(403, 'You can only tick off your own chores'); if (who && who !== me.id && !admin) throw fail(403, 'You can only tick off your own chores');
const idx = db().log.findIndex(l => l.chore === c.id && l.key === key && (who === null || l.user === who)); const idx = db().log.findIndex(l => l.chore === c.id && l.key === key && (who === null || l.user === who));
if (b.undo) { if (idx < 0) return json(res, 200, { ok: true }); const l = db().log[idx]; if (l.by !== me.id && !admin) throw fail(403, 'Only the person who ticked it can take it back'); db().log.splice(idx, 1); save(); return json(res, 200, { ok: true }); } if (b.undo) { if (idx < 0) return json(res, 200, { ok: true }); const l = db().log[idx]; if (l.by !== me.id && !admin) throw fail(403, 'Only the person who ticked it can take it back'); db().log.splice(idx, 1); save(); return json(res, 200, { ok: true }); }
if (idx >= 0) return json(res, 200, { ok: true, already: true }); if (idx >= 0) return json(res, 200, { ok: true, already: true });
const by = who || me.id, entry = { chore: c.id, key, date, user: who, by, at: Date.now(), pts: c.points }; db().log.push(entry); if (db().log.length > MAX_LOG) db().log.splice(0, db().log.length - MAX_LOG); save(); return json(res, 200, { ok: true, pts: c.points }); const by = who || me.id, entry = { chore: c.id, key, date, user: who, by, at: Date.now(), pts: c.points }; db().log.push(entry); if (db().log.length > MAX_LOG) db().log.splice(0, db().log.length - MAX_LOG); save(); return json(res, 200, { ok: true, pts: c.points });
} }
if (p === '/claim' && req.method === 'POST') { // take a chore from the "anyone" pool (or put it back)
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = db().chores.find(x => x.id === String(b.chore)), date = b.date || ymd(new Date()); if (!c) throw fail(404, 'Chore not found'); if (!ymdOk(date)) throw fail(400, 'Bad date');
if (!(c.assign === 'anyone' || !assignees(c).length)) throw fail(400, 'That chore is given to particular people'); const key = dueKey(c, date); if (!key) throw fail(400, 'That chore is not due on that day');
const d = db(), i = d.claims.findIndex(x => x.chore === c.id && x.key === key);
if (b.undo) { if (i < 0) return json(res, 200, { ok: true }); if (d.claims[i].user !== me.id && !admin) throw fail(403, 'Only the person who took it can put it back'); d.claims.splice(i, 1); save(); return json(res, 200, { ok: true }); }
if (d.log.some(l => l.chore === c.id && l.key === key)) throw fail(400, 'That one is already done');
if (i >= 0 && live(d.claims[i].user)) { if (d.claims[i].user === me.id) return json(res, 200, { ok: true, already: true }); throw fail(409, nameOf(d.claims[i].user) + ' already took this one'); }
if (i >= 0) d.claims.splice(i, 1); d.claims.push({ chore: c.id, key, user: me.id, at: Date.now() }); if (d.claims.length > 2000) d.claims.splice(0, d.claims.length - 2000); save(); return json(res, 200, { ok: true });
}
if (p === '/summary' && req.method === 'GET') return json(res, 200, summary(q.get('range') === 'month' ? 'month' : 'week', day)); 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' }); 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 }); } } 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 }); }
+2 -1
View File
@@ -15,7 +15,7 @@ async function viewChores() {
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; } 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; 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 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 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.claimed && !i.done && i.user !== me ? `<small>${esc(i.userName)} took this</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>` : ''}${i.claimable && !i.done ? `<button type="button" class="btn ghost crclaim" data-c="${esc(i.chore)}">Take it</button>` : ''}${i.claimed && !i.done && (i.user === me || d.me.admin) ? `<button type="button" class="btn ghost crclaim" data-c="${esc(i.chore)}" data-undo="1">Put back</button>` : ''}</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 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)); 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> 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>
@@ -25,6 +25,7 @@ async function viewChores() {
${others.length ? `<h2>The family</h2><div class="crgrid">${others.map(person).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="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>`; <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('.crclaim').forEach(b => b.onclick = async e => { e.preventDefault(); try { await crPost('/claim', { chore: b.dataset.c, date: cr.day, undo: !!b.dataset.undo }); if (!b.dataset.undo) toast('It is yours'); viewChores(); } catch (x) { toast(x.message); viewChores(); } });
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; } }); 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(); }; $('#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(); }); v.querySelectorAll('[data-r]').forEach(a => a.onclick = e => { e.preventDefault(); cr.range = a.dataset.r; viewChores(); });
+1 -1
View File
@@ -25,7 +25,7 @@ async function fmEvents() {
} }
async function fmChores() { async function fmChores() {
const d = await crApi('/?date=' + crYmd(new Date())), b = fmBody('fmch'); if (!b) return; const me = d.me.id; const d = await crApi('/?date=' + crYmd(new Date())), b = fmBody('fmch'); if (!b) return; const me = d.me.id;
const per = d.people.map(p => { const l = d.board.filter(i => i.user === p.id), dn = l.filter(i => i.done).length; return { p, l, dn }; }).filter(x => x.l.length), any = d.board.filter(i => i.user === null); const per = d.people.filter(p => p.board !== false).map(p => { const l = d.board.filter(i => i.user === p.id), dn = l.filter(i => i.done).length; return { p, l, dn }; }).filter(x => x.l.length), any = d.board.filter(i => i.user === null);
const mine = d.board.filter(i => i.user === me || i.user === null).filter(i => !i.done); const mine = d.board.filter(i => i.user === me || i.user === null).filter(i => !i.done);
b.innerHTML = `${mine.length ? mine.slice(0, 5).map(i => `<label class="critem"><input type="checkbox" data-c="${esc(i.chore)}" data-u="${esc(i.user || '')}"><span class="grow"><b>${esc(i.title)}</b></span>${i.points ? `<em class="crpts">${i.points} pts</em>` : ''}</label>`).join('') : '<p class="empty" style="margin:6px 0">Nothing left for you today. 🎉</p>'} b.innerHTML = `${mine.length ? mine.slice(0, 5).map(i => `<label class="critem"><input type="checkbox" data-c="${esc(i.chore)}" data-u="${esc(i.user || '')}"><span class="grow"><b>${esc(i.title)}</b></span>${i.points ? `<em class="crpts">${i.points} pts</em>` : ''}</label>`).join('') : '<p class="empty" style="margin:6px 0">Nothing left for you today. 🎉</p>'}
${per.length ? `<div class="fmprog">${per.map(x => `<div class="fmpr"><span class="sgav" style="background:${sgColor(x.p.id)};width:26px;height:26px;font-size:12px">${esc(sgInitial(x.p.name))}</span><span class="grow"><small>${esc(x.p.name)} · ${x.dn} of ${x.l.length}</small><div class="crbar"><i style="width:${Math.round(x.dn / x.l.length * 100)}%"></i></div></span></div>`).join('')}</div>` : ''}${any.length ? `<small class="sub">${any.filter(i => !i.done).length} for anyone</small>` : ''}`; ${per.length ? `<div class="fmprog">${per.map(x => `<div class="fmpr"><span class="sgav" style="background:${sgColor(x.p.id)};width:26px;height:26px;font-size:12px">${esc(sgInitial(x.p.name))}</span><span class="grow"><small>${esc(x.p.name)} · ${x.dn} of ${x.l.length}</small><div class="crbar"><i style="width:${Math.round(x.dn / x.l.length * 100)}%"></i></div></span></div>`).join('')}</div>` : ''}${any.length ? `<small class="sub">${any.filter(i => !i.done).length} for anyone</small>` : ''}`;
+2 -2
View File
@@ -42,7 +42,7 @@ async function addPersonSheet() {
function personSheet(u) { function personSheet(u) {
const self = u.id === ME.id, admin = u.role === 'admin'; const self = u.id === ME.id, admin = u.role === 'admin';
const el = openSheet(u.name, admin ? 'Admin' : u.disabled ? 'Turned off' : 'User', `<form id="uf"><label for="un">Name</label><input type="text" id="un" value="${esc(u.name)}" autocomplete="off" required> const el = openSheet(u.name, admin ? 'Admin' : u.disabled ? 'Turned off' : 'User', `<form id="uf"><label for="un">Name</label><input type="text" id="un" value="${esc(u.name)}" autocomplete="off" required><label class="row" style="cursor:pointer"><span class="grow"><b>Show on the family board</b><small style="white-space:normal">Their name appears on the family screen, and their chores in the family progress</small></span><input type="checkbox" id="ubd"${u.board === false ? '' : ' checked'}></label>
${admin ? '<p class="sub">Admins can use everything.</p>' : `<label>What can they use?</label>${tabBoxes(u.tabsChosen)} ${admin ? '<p class="sub">Admins can use everything.</p>' : `<label>What can they use?</label>${tabBoxes(u.tabsChosen)}
<h2 style="margin-top:14px">Child account</h2><label class="row" style="cursor:pointer"><span class="grow"><b>This is a child</b><small style="white-space:normal">Adds a daily time limit and a bedtime, and a safer Store and podcast search.</small></span><input type="checkbox" id="kon"${u.kid && u.kid.on ? ' checked' : ''}></label> <h2 style="margin-top:14px">Child account</h2><label class="row" style="cursor:pointer"><span class="grow"><b>This is a child</b><small style="white-space:normal">Adds a daily time limit and a bedtime, and a safer Store and podcast search.</small></span><input type="checkbox" id="kon"${u.kid && u.kid.on ? ' checked' : ''}></label>
<div id="kopt" ${u.kid && u.kid.on ? '' : 'hidden'}><label for="klim">Time per day, in minutes (empty for no limit)</label><input type="number" id="klim" min="0" max="720" value="${u.kid && u.kid.limitMin ? u.kid.limitMin : ''}" placeholder="120"> <div id="kopt" ${u.kid && u.kid.on ? '' : 'hidden'}><label for="klim">Time per day, in minutes (empty for no limit)</label><input type="number" id="klim" min="0" max="720" value="${u.kid && u.kid.limitMin ? u.kid.limitMin : ''}" placeholder="120">
@@ -65,7 +65,7 @@ function personSheet(u) {
kon.onchange = () => { el.querySelector('#kopt').hidden = !kon.checked; }; fillUse(); kon.onchange = () => { el.querySelector('#kopt').hidden = !kon.checked; }; fillUse();
el.querySelectorAll('#kuse [data-m]').forEach(b => b.onclick = async () => { try { await jfetch('/api/kid/extra', { method: 'POST', body: JSON.stringify({ user: u.id, minutes: +b.dataset.m }) }); toast('Added ' + b.dataset.m + ' minutes for today'); fillUse(); } catch (err) { toast(err.message); } }); el.querySelectorAll('#kuse [data-m]').forEach(b => b.onclick = async () => { try { await jfetch('/api/kid/extra', { method: 'POST', body: JSON.stringify({ user: u.id, minutes: +b.dataset.m }) }); toast('Added ' + b.dataset.m + ' minutes for today'); fillUse(); } catch (err) { toast(err.message); } });
} }
el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, ...(admin ? {} : { tabs: pickedTabs(el), kid: { on: el.querySelector('#kon').checked, limitMin: +el.querySelector('#klim').value || 0, bedtime: { on: el.querySelector('#kbo').checked, from: el.querySelector('#kbf').value, to: el.querySelector('#kbt').value }, safe: el.querySelector('#ksf').checked } }) }), 'Saved'); }; el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, board: el.querySelector('#ubd').checked, ...(admin ? {} : { tabs: pickedTabs(el), kid: { on: el.querySelector('#kon').checked, limitMin: +el.querySelector('#klim').value || 0, bedtime: { on: el.querySelector('#kbo').checked, from: el.querySelector('#kbf').value, to: el.querySelector('#kbt').value }, safe: el.querySelector('#ksf').checked } }) }), 'Saved'); };
el.querySelector('#u-inv').onclick = async () => { try { const r = await jfetch('/api/users/' + encodeURIComponent(u.id) + '/invite', { method: 'POST', body: '{}' }); codeSheet(u.name, r.code, r.minutes); } catch (err) { toast(err.message); } }; el.querySelector('#u-inv').onclick = async () => { try { const r = await jfetch('/api/users/' + encodeURIComponent(u.id) + '/invite', { method: 'POST', body: '{}' }); codeSheet(u.name, r.code, r.minutes); } catch (err) { toast(err.message); } };
el.querySelector('#u-out').onclick = () => act(() => put({ signOutEverywhere: true }), u.name + ' was signed out everywhere'); el.querySelector('#u-out').onclick = () => act(() => put({ signOutEverywhere: true }), u.name + ' was signed out everywhere');
const off = el.querySelector('#u-off'); if (off) off.onclick = () => act(() => put({ disabled: !u.disabled }), u.disabled ? 'Turned back on' : 'Turned off'); const off = el.querySelector('#u-off'); if (off) off.onclick = () => act(() => put({ disabled: !u.disabled }), u.disabled ? 'Turned back on' : 'Turned off');
+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-v73'; const CACHE = 'hub-v74';
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'];
+1
View File
@@ -16,6 +16,7 @@ const hub = http.createServer((req, res) => board.handle(req, res)).listen(0, as
let r = await post('/api/device/enable', { name: 'Kitchen' }, session(ann)); assert.equal(r.status, 200); const dev = r.set.find(c => c.startsWith('hub_device=')).split(';')[0]; assert.ok(dev); let r = await post('/api/device/enable', { name: 'Kitchen' }, session(ann)); assert.equal(r.status, 200); const dev = r.set.find(c => c.startsWith('hub_device=')).split(';')[0]; assert.ok(dev);
assert.ok(r.set.some(c => c.startsWith('hub_session=;') ), 'the admin is signed out of the device'); assert.ok(r.set.some(c => c.startsWith('hub_session=;') ), 'the admin is signed out of the device');
r = await call('/api/board?day=2026-10-26', {}, dev); assert.equal(r.status, 200); assert.deepEqual(r.json.people.map(p => [p.name, p.kid]), [['Ann', false], ['Kai', true], ['Bob', false]]); r = await call('/api/board?day=2026-10-26', {}, dev); assert.equal(r.status, 200); assert.deepEqual(r.json.people.map(p => [p.name, p.kid]), [['Ann', false], ['Kai', true], ['Bob', false]]);
users.update(bob.id, { board: false }); r = await call('/api/board?day=2026-10-26', {}, dev); assert.deepEqual(r.json.people.map(p => p.name), ['Ann', 'Kai'], 'only the people the admin picks are on the board'); assert.equal((await post('/api/board/switch', { uid: bob.id, pin: '2468' }, dev)).status, 404); users.update(bob.id, { board: true });
assert.equal((await call('/api/board', {}, 'hub_device=fake.sig')).status, 403, 'a forged device cookie is refused'); assert.equal((await call('/api/board', {}, 'hub_device=fake.sig')).status, 403, 'a forged device cookie is refused');
r = await post('/api/board/switch', { uid: kid.id }, dev); assert.equal(r.status, 200, JSON.stringify(r.json)); const sess = r.set.find(c => c.startsWith('hub_session=')); assert.ok(/Max-Age=1800/.test(sess), 'a short session on a family device'); assert.equal(auth.currentUser({ headers: { cookie: sess.split(';')[0] } }).id, kid.id); r = await post('/api/board/switch', { uid: kid.id }, dev); assert.equal(r.status, 200, JSON.stringify(r.json)); const sess = r.set.find(c => c.startsWith('hub_session=')); assert.ok(/Max-Age=1800/.test(sess), 'a short session on a family device'); assert.equal(auth.currentUser({ headers: { cookie: sess.split(';')[0] } }).id, kid.id);
r = await post('/api/board/switch', { uid: bob.id }, dev); assert.equal(r.status, 403, 'an adult with no PIN cannot just tap'); assert.equal((await post('/api/board/switch', { uid: kid.id }, '')).status, 403, 'and only a family device can switch'); r = await post('/api/board/switch', { uid: bob.id }, dev); assert.equal(r.status, 403, 'an adult with no PIN cannot just tap'); assert.equal((await post('/api/board/switch', { uid: kid.id }, '')).status, 403, 'and only a family device can switch');
+6
View File
@@ -30,6 +30,12 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
const once = await j(await post('/', { title: 'Fix the gate', repeat: 'once', date: chores.addDays(today, -2), assign: 'anyone', points: 8 })); let ob = (await board('b')).find(x => x.title === 'Fix the gate'); assert.equal(ob.overdue, true); await post('/done', { chore: once.id, date: today }, 'b'); ob = (await board('b')).find(x => x.title === 'Fix the gate'); assert.equal(ob.done.byName, 'Bob'); assert.equal(ob.overdue, false); const once = await j(await post('/', { title: 'Fix the gate', repeat: 'once', date: chores.addDays(today, -2), assign: 'anyone', points: 8 })); let ob = (await board('b')).find(x => x.title === 'Fix the gate'); assert.equal(ob.overdue, true); await post('/done', { chore: once.id, date: today }, 'b'); ob = (await board('b')).find(x => x.title === 'Fix the gate'); assert.equal(ob.done.byName, 'Bob'); assert.equal(ob.overdue, false);
// points // points
const sm = await j(await call('/summary?range=week', {}, 'a')); const bob = sm.people.find(x => x.name === 'Bob'), cy = sm.people.find(x => x.name === 'Cy'); assert.equal(bob.points, 3 + 8, 'Bob: dishes 3 and the gate 8'); assert.equal(cy.points, 3, 'Cy: dishes ticked by an admin counts for Cy'); assert.equal(sm.people[0].name, 'Bob', 'most points first'); assert.equal(bob.perDay.length, 7); assert.equal(bob.perDay.at(-1), 2); const sm = await j(await call('/summary?range=week', {}, 'a')); const bob = sm.people.find(x => x.name === 'Bob'), cy = sm.people.find(x => x.name === 'Cy'); assert.equal(bob.points, 3 + 8, 'Bob: dishes 3 and the gate 8'); assert.equal(cy.points, 3, 'Cy: dishes ticked by an admin counts for Cy'); assert.equal(sm.people[0].name, 'Bob', 'most points first'); assert.equal(bob.perDay.length, 7); assert.equal(bob.perDay.at(-1), 2);
// taking a chore from the pool
const sweep = await j(await post('/', { title: 'Sweep', repeat: 'daily', assign: 'anyone', points: 2 })); let sw = (await board('b')).find(x => x.title === 'Sweep'); assert.equal(sw.claimable, true); assert.equal(sw.user, null);
assert.equal((await post('/claim', { chore: sweep.id, date: today }, 'b')).status, 200); sw = (await board('b')).find(x => x.title === 'Sweep'); assert.equal(sw.user, P.b.id, 'it is now Bob\'s'); assert.equal(sw.claimed, true); assert.equal(sw.claimable, false);
assert.equal((await post('/claim', { chore: sweep.id, date: today }, 'c')).status, 409, 'someone else cannot take it too'); assert.equal((await post('/done', { chore: sweep.id, date: today }, 'c')).status, 403, 'or tick it off');
assert.equal((await post('/claim', { chore: sweep.id, date: today, undo: true }, 'c')).status, 403, 'or put it back'); assert.equal((await post('/claim', { chore: sweep.id, date: today, undo: true }, 'b')).status, 200); assert.equal((await board('b')).find(x => x.title === 'Sweep').claimable, true, 'put back in the pool');
await post('/claim', { chore: sweep.id, date: today }, 'c'); assert.equal((await post('/done', { chore: sweep.id, date: today }, 'c')).status, 200); sw = (await board('b')).find(x => x.title === 'Sweep'); assert.ok(sw.done && sw.user === P.c.id, 'done by the one who took it'); assert.equal((await post('/claim', { chore: sweep.id, date: today, undo: true }, 'c')).status, 200); assert.equal((await post('/claim', { chore: dishes.id, date: today }, 'b')).status, 400, 'only pool chores');
// delete removes its history // 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('/' + 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); assert.equal((await call('/?date=nope')).status, 400);
+2 -1
View File
@@ -61,6 +61,7 @@ function update(id, patch) {
u.disabled = d; u.epoch++; // disabling also signs them out everywhere u.disabled = d; u.epoch++; // disabling also signs them out everywhere
} }
if (patch.kid !== undefined) { const k = require('./kidlimits').cleanKid(patch.kid); if (u.role === 'admin' && k && k.on) throw new Error('An admin cannot be a child account'); if (k) u.kid = k; else delete u.kid; } if (patch.kid !== undefined) { const k = require('./kidlimits').cleanKid(patch.kid); if (u.role === 'admin' && k && k.on) throw new Error('An admin cannot be a child account'); if (k) u.kid = k; else delete u.kid; }
if (patch.board !== undefined) { if (patch.board === false) u.board = false; else delete u.board; } // whether they are on the family board and in family chore progress
if (patch.signOutEverywhere) u.epoch++; if (patch.signOutEverywhere) u.epoch++;
save(); return u; save(); return u;
} }
@@ -74,7 +75,7 @@ function remove(id) {
const can = (u, tab) => !!u && (ALWAYS.includes(tab) || u.role === 'admin' || (u.tabs || []).includes(tab)); const can = (u, tab) => !!u && (ALWAYS.includes(tab) || u.role === 'admin' || (u.tabs || []).includes(tab));
const allowedTabs = u => !u ? [] : [...ALWAYS, ...(u.role === 'admin' ? TABS : (u.tabs || []).filter(t => TABS.includes(t)))]; const allowedTabs = u => !u ? [] : [...ALWAYS, ...(u.role === 'admin' ? TABS : (u.tabs || []).filter(t => TABS.includes(t)))];
const publicView = u => ({ id: u.id, name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created, ...(u.kid && u.kid.on ? { kid: u.kid } : {}) }); const publicView = u => ({ id: u.id, name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created, ...(u.kid && u.kid.on ? { kid: u.kid } : {}), board: u.board !== false });
// ---- A single-user install becomes "the admin" without anyone doing anything: their passkey, tasks, events, settings, // ---- A single-user install becomes "the admin" without anyone doing anything: their passkey, tasks, events, settings,
// iCloud connection and calendar links move into their own folder. (Copied, so the originals stay as a backup.) // iCloud connection and calendar links move into their own folder. (Copied, so the originals stay as a backup.)