Family board: add to shopping, tasks, checklists and the Family calendar; record who added each
build-container / build (push) Successful in 4m27s

- + button on each board card: write it, tap your name (PIN unless a child), it is added as you
- Tasks, events, checklist items and shopping items remember who added them
- Names shown on the board and in Tasks, Notes and Calendar
- 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 14:01:40 -04:00
1 parent 472e7bec7e
commit f87a218eea
13 files changed
+86 -27

No files matched your search

+1 -1
View File
@@ -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`.
**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. Chores on the board are tappable: tap one (up-for-grabs chores ask *I did it* or *Take it for later*), tap your name, and enter your PIN if you are not a child; the chore is then done or claimed as you, with all the usual chore rules, without signing in. 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.
**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. Each board card has a **+** to add to the shopping list, the family tasks, a shared checklist (pick which) or the Family calendar (all day, or at a time): write it, tap your name (PIN unless you are a child) and it is added as you, if that part of the hub is turned on for you. Chores on the board are tappable: tap one (up-for-grabs chores ask *I did it* or *Take it for later*), tap your name, and enter your PIN if you are not a child; the chore is then done or claimed as you, with all the usual chore rules, without signing in. 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.
+17
View File
@@ -35,6 +35,11 @@ function identify(t, pin, req) {
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, pin)) { audit.log('family device: wrong PIN', { who: t.name }, req); throw fail(401, 'That PIN is not right'); }
}
function eventFields(title, b) {
const day = /^\d{4}-\d{2}-\d{2}$/.test(b.date || '') ? b.date : new Date().toISOString().slice(0, 10), time = /^\d{2}:\d{2}$/.test(b.time || '') ? b.time : '';
if (!time) return { title, allDay: true, start: day, end: day };
const end = String((+time.slice(0, 2) + 1) % 24).padStart(2, '0') + time.slice(2); return { title, allDay: false, start: day + 'T' + time, end: +time.slice(0, 2) === 23 ? day + 'T23:59' : day + 'T' + end, tz: b.tz || 'UTC' };
}
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 {}
@@ -52,6 +57,18 @@ 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/add' && req.method === 'POST') { // put something on one of the family's lists from the shared screen, as the person who picked their name
if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 8e3) || '{}'), t = users.get(String(b.uid));
if (!t || !onBoard(t)) throw fail(404, 'Person not found'); const text = String(b.text || '').replace(/\s+/g, ' ').trim().slice(0, 200); if (!text) throw fail(400, 'Write something first');
const plan = { shopping: ['recipes', recipes, '/api/recipes/shopping/add', { text }],
task: ['tasks', tasks, '/api/tasks', { list: 'local:family', fields: { title: text, due: /^\d{4}-\d{2}-\d{2}$/.test(b.due || '') ? b.due : '' } }],
item: ['notes', notes, '/api/notes/' + String(b.note || '') + '/items', { text }],
event: ['calendar', cal, '/api/cal/event', { cal: 'family~local', fields: eventFields(text, b) }] }[b.kind];
if (!plan) throw fail(400, 'Unknown list'); if (!users.can(t, plan[0])) throw fail(403, 'That part of the hub is not turned on for ' + t.name);
if (b.kind === 'item') { const n = notes.familyNotes().find(x => x.id === String(b.note) && x.type === 'list'); if (!n) throw fail(404, 'That list is not shared with the family'); }
identify(t, b.pin, req); const r = Readable.from([Buffer.from(JSON.stringify(plan[3]))]); r.url = plan[2]; r.method = 'POST'; r.headers = {};
return users.runAs(t, () => plan[1].handle(r, res)); // (as that person, so that person's own rules apply)
}
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 (!t || !onBoard(t)) throw fail(404, 'Person not found');
+4 -4
View File
@@ -326,7 +326,7 @@ async function collectEvents(win) {
const mine = dirFor(myId()), evs = eventsIn(mine);
const add = (dir, evs, c, id, extra) => {
const ics_ = ics.buildCalendar(evs.filter(e => evCal(e) === c.id).map(e => ({ uid: e.uid, fields: e.fields })));
for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: over[id] || c.color || DEFAULT_COLOR, ...extra });
for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: over[id] || c.color || DEFAULT_COLOR, by: (evs.find(x => x.uid === e.uid) || {}).byName || '', ...extra });
};
for (const c of calsIn(mine)) add(mine, evs, c, c.id, { editable: true });
for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName });
@@ -436,7 +436,7 @@ async function handle(req, res) {
ics.validate(b.fields);
const all = eventsIn(r.dir), uid = b.uid || ics.newUid(), i = all.findIndex(x => x.uid === uid && evCal(x) === r.cid);
const rec = { uid, cal: r.cid, fields: b.fields, updated: Date.now() };
if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now() });
if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now(), by: users.current().id, byName: users.current().name }); // (who added it)
saveEvents(r.dir, all);
return json(res, 200, { ok: true, uid });
}
@@ -479,7 +479,7 @@ async function handle(req, res) {
/** The Family calendar's events in a window, for the family board (no signed-in person needed). */
function familyEvents(win) {
const ics_ = ics.buildCalendar(eventsIn(dirFor(FAM)).map(e => ({ uid: e.uid, fields: e.fields })));
return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: FAMCAL.color }));
const evs = eventsIn(dirFor(FAM)), ics_ = ics.buildCalendar(evs.map(e => ({ uid: e.uid, fields: e.fields })));
return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: FAMCAL.color, by: (evs.find(x => x.uid === e.uid) || {}).byName || '' }));
}
module.exports = { familyEvents, collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs };
+1 -1
View File
@@ -46,7 +46,7 @@ async function handle(req, res) {
if ((m = /^\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { const n = find(m[1], me, true); cache = all().filter(x => x !== n); save(); return json(res, 200, { ok: true }); }
if ((m = /^\/([0-9a-f]{12})\/items$/.exec(p)) && req.method === 'POST') { // one change to a checklist
const n = find(m[1], me); if (n.type !== 'list') throw fail(400, 'That is not a checklist'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), it = id => n.items.find(x => x.id === String(id));
if (b.text !== undefined && !b.edit) { const t = str(b.text, 300).trim(); if (!t) throw fail(400, 'Write the item'); if (n.items.length >= MAX_ITEMS) throw fail(400, 'That is a long list'); n.items.push({ id: newId(), text: t, done: false }); }
if (b.text !== undefined && !b.edit) { const t = str(b.text, 300).trim(); if (!t) throw fail(400, 'Write the item'); if (n.items.length >= MAX_ITEMS) throw fail(400, 'That is a long list'); n.items.push({ id: newId(), text: t, done: false, addedBy: me.name }); }
else if (b.toggle) { const x = it(b.toggle); if (!x) throw fail(404, 'Item not found'); x.done = !!b.done; if (x.done) { x.by = me.id; x.byName = me.name; x.at = Date.now(); } else { delete x.by; delete x.byName; delete x.at; } }
else if (b.edit) { const x = it(b.edit), t = str(b.text, 300).trim(); if (!x || !t) throw fail(400, 'Write the item'); x.text = t; }
else if (b.remove) n.items = n.items.filter(x => x.id !== String(b.remove));
+2
View File
@@ -65,6 +65,8 @@
#ov { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: grid; place-items: center; padding: 18px; } #ov[hidden] { display: none; }
#ovb { background: var(--panel); border: 1px solid var(--line); border-radius: 20px; padding: 20px; width: 100%; max-width: 380px; text-align: center; }
.err { color: var(--danger); min-height: 20px; }
.pbtn { width: 34px; height: 34px; border-radius: 50%; background: var(--accent); color: var(--accent-t, #fff); border: 0; font-size: 24px; line-height: 1; cursor: pointer; flex: none; }
#ovb input[type=text], #ovb input[type=date], #ovb input[type=time], #ovb select { width: 100%; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); font-size: 17px; }
.pill { display: inline-flex; align-items: center; gap: 6px; margin: 3px 6px 3px 0; padding: 6px 12px; border-radius: 16px; background: var(--panel2); border: 1px solid var(--line); color: var(--text); font: inherit; font-size: 15px; cursor: pointer; } .pill.pool { border-color: var(--accent); } .pill small { color: var(--muted); font-size: 12px; } .pill.more { background: none; border-style: dashed; color: var(--muted); cursor: default; }
.chp { margin-top: 8px; } .chp .top { display: flex; justify-content: space-between; align-items: baseline; }
/* a tablet (like a 12.9-inch iPad) shows the whole board at once, with no scrolling */
+34 -7
View File
@@ -6,7 +6,7 @@ const ymd = d => d.getFullYear() + '-' + String(d.getMonth() + 1).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' }); }
const card = (t, body) => `<section class="card"><h2>${t}</h2>${body}</section>`;
const card = (t, body, add) => `<section class="card"><h2 style="display:flex;justify-content:space-between;align-items:center">${t}${add ? `<button class="pbtn" data-add="${add}" aria-label="Add">+</button>` : ''}</h2>${body}</section>`;
function weekCard(w) {
if (!w || !w.people.length) return none('No points yet');
return w.people.map(p => { const mine = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...mine.map(t => t.points), p.points), next = mine.find(t => t.points > p.points), got = mine.filter(t => p.earned.includes(t.id)); return `<div style="margin-top:8px"><div style="display:flex;justify-content:space-between"><b>${esc(p.name)}</b><small class="sub">${p.points} pts · ${p.count} done</small></div><div class="bar2" style="position:relative">${mine.map(t => `<span style="position:absolute;top:0;left:${Math.round(t.points / top * 100)}%;width:2px;height:100%;background:var(--text);opacity:.5"></span>`).join('')}<i style="width:${Math.round(p.points / top * 100)}%"></i></div><small>${got.length ? '🎁 ' + esc(got.map(t => t.title).join(', ')) : ''}${next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}</small></div>`; }).join('');
@@ -18,22 +18,22 @@ function draw() {
$('#annc').innerHTML = d.announcement ? `<div class="ann">${esc(d.announcement.text)}<div class="sub" style="font-size:14px;font-weight:400">${esc(d.announcement.by)}</div></div>` : '';
$('#tiles').innerHTML = d.people.map(p => `<button class="tile" data-id="${esc(p.id)}"><span class="av" style="background:${p.color}">${esc((p.name.trim()[0] || '?').toUpperCase())}</span><b>${esc(p.name.split(' ')[0])}</b><small>${p.kid ? '<span class="kid">KID</span>' : ''}</small></button>`).join('');
$('#tiles').querySelectorAll('.tile').forEach(b => b.onclick = () => pick(d.people.find(p => p.id === b.dataset.id)));
const day = (label, ds) => { const l = d.events.filter(e => e.allDay ? e.start <= ds && e.end > ds : ymd(new Date(e.start)) === ds).sort((a, b) => (b.allDay - a.allDay) || a.start - b.start); return `<small class="sub">${label}</small>` + (l.map(e => `<div class="ln"><span class="dot" style="background:${esc(e.color || 'var(--accent)')}"></span><span class="g">${esc(e.title)}</span><small>${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</small></div>`).join('') || none('Nothing')); };
const day = (label, ds) => { const l = d.events.filter(e => e.allDay ? e.start <= ds && e.end > ds : ymd(new Date(e.start)) === ds).sort((a, b) => (b.allDay - a.allDay) || a.start - b.start); return `<small class="sub">${label}</small>` + (l.map(e => `<div class="ln"><span class="dot" style="background:${esc(e.color || 'var(--accent)')}"></span><span class="g">${esc(e.title)}${e.by ? ` <small>${esc(e.by)}</small>` : ''}</span><small>${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</small></div>`).join('') || none('Nothing')); };
const pill = (c, cls) => `<button class="pill ${cls || ''}" data-c="${esc(c.chore)}">${esc(c.title)}${c.points ? ` <small>${c.points} pts</small>` : ''}</button>`;
const pool = d.chores.filter(c => c.claimable && !c.done), per = {}; d.chores.filter(c => !(c.claimable && !c.done)).forEach(c => { (per[c.userName] = per[c.userName] || []).push(c); });
const SHOW = 4, chores = (pool.length ? `<small class="sub">Up for grabs: tap one</small><div>${pool.slice(0, 6).map(c => pill(c, 'pool')).join('')}${pool.length > 6 ? `<button class="pill more" id="poolall">See all ${pool.length}</button>` : ''}</div>` : '') + Object.keys(per).map(k => { const l = per[k], dn = l.filter(c => c.done).length, left = l.filter(c => !c.done); return `<div class="chp"><div class="top"><b>${esc(k)}</b><small class="sub">${dn} of ${l.length}</small></div><div class="bar2"><i style="width:${Math.round(dn / l.length * 100)}%"></i></div><div>${left.slice(0, SHOW).map(c => pill(c)).join('')}${left.length > SHOW ? `<span class="pill more">+${left.length - SHOW}</span>` : ''}${left.length ? '' : '<small class="sub">All done 🎉</small>'}</div></div>`; }).join('');
$('#grid').innerHTML = card('Today and tomorrow', day('Today', t0) + day('Tomorrow', t1))
$('#grid').innerHTML = card('Today and tomorrow', day('Today', t0) + day('Tomorrow', t1), 'event')
+ card('Chores today', chores || none('No chores today'))
+ card('This week', weekCard(d.week)) + card('Shopping list', d.shopping.slice(0, 10).map(i => `<div class="ln"><span class="g">${esc(i.text)}</span></div>`).join('') || none('Nothing to buy'))
+ card('Family tasks', d.tasks.slice(0, 6).map(t => `<div class="ln"><span class="g">${esc(t.title)}</span><small style="${t.due && t.due < t0 ? 'color:var(--danger)' : ''}">${t.due === t0 ? 'Today' : esc(t.due)}</small></div>`).join('') || none('Nothing due'))
+ card('Family notes and lists', d.notes.slice(0, 4).map(n => `<div class="ln"><span class="g"><b>${esc(n.title)}</b>${n.type === 'list' ? ` <small>${n.done} of ${n.total} done${n.next ? ' · next: ' + esc(n.next) : ''}</small>` : ` <small>${esc(n.body)}</small>`}</span></div>`).join('') || none('No shared notes'));
+ card('This week', weekCard(d.week)) + card('Shopping list', d.shopping.slice(0, 10).map(i => `<div class="ln"><span class="g">${esc(i.text)}</span>${i.by ? `<small>${esc(i.by)}</small>` : ''}</div>`).join('') || none('Nothing to buy'), 'shopping')
+ card('Family tasks', d.tasks.slice(0, 6).map(t => `<div class="ln"><span class="g">${esc(t.title)}${t.by ? ` <small>${esc(t.by)}</small>` : ''}</span><small style="${t.due && t.due < t0 ? 'color:var(--danger)' : ''}">${t.due === t0 ? 'Today' : esc(t.due)}</small></div>`).join('') || none('Nothing due'), 'task')
+ card('Family notes and lists', d.notes.slice(0, 4).map(n => `<div class="ln"><span class="g"><b>${esc(n.title)}</b>${n.type === 'list' ? ` <small>${n.done} of ${n.total} done${n.next ? ' · next: ' + esc(n.next) : ''}</small>` : ` <small>${esc(n.body)}</small>`}</span></div>`).join('') || none('No shared notes'), d.notes.some(n => n.type === 'list') ? 'item' : '');
wireChores();
}
function poolAll() {
const list = data.chores.filter(c => c.claimable && !c.done), { box, close } = sheet(`<h3 style="margin:0 0 4px">Up for grabs</h3><p class="sub">${list.length} chores. Tap one to pick it</p><div style="text-align:left;max-height:58dvh;overflow:auto">${list.map(c => `<button class="pill pool" data-c="${esc(c.chore)}" style="width:100%;justify-content:space-between;font-size:18px;padding:12px 14px;margin:4px 0"><span>${esc(c.title)}</span>${c.points ? `<small>${c.points} pts</small>` : ''}</button>`).join('')}</div><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Close</button></p>`);
box.querySelector('#no').onclick = close; box.querySelectorAll('.pill[data-c]').forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c)));
}
function wireChores() { 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))); }
function wireChores() { document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFlow(b.dataset.add)); const all = document.getElementById('poolall'); if (all) all.onclick = poolAll; document.querySelectorAll(".pill[data-c]").forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c))); }
async function load() { try { const r = await fetch('/api/board?day=' + ymd(new Date())); if (r.status === 403) { location.replace('/login'); return; } data = await r.json(); draw(); } catch {} }
function pick(p) {
@@ -73,3 +73,30 @@ function choreTap(c) {
if (c.claimable) { const { box, close } = sheet(`${title}<p class="sub">${c.points ? c.points + ' points' : 'Up for grabs'}</p><button class="btn" id="did" style="width:100%;min-height:52px;justify-content:center;margin-bottom:10px">I did it</button><button class="btn ghost" id="take" style="width:100%;min-height:52px;justify-content:center">Take it for later</button><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Cancel</button></p>`); box.querySelector('#no').onclick = close; box.querySelector('#did').onclick = () => who('done'); box.querySelector('#take').onclick = () => who('claim'); return; }
who('done');
}
// ---- add to a family list: write it, pick your name (PIN unless you are a child), and it is added as you
const ADD = {
shopping: { title: 'Add to the shopping list', ph: 'Milk, bread...', done: 'Added to the shopping list' },
task: { title: 'Add a family task', ph: 'What needs doing?', due: true, done: 'Task added' },
item: { title: 'Add to a list', ph: 'What to add?', lists: true, done: 'Added' },
event: { title: 'Add to the family calendar', ph: 'What is happening?', when: true, done: 'Event added' },
};
function addFlow(kind) {
const a = ADD[kind]; if (!a) return; const today = ymd(new Date());
const lists = data.notes.filter(n => n.type === 'list'), { box, close } = sheet(`<h3 style="margin:0 0 8px">${a.title}</h3><form id="af" style="text-align:left">${a.lists ? `<select id="al" style="margin-bottom:8px">${lists.map(n => `<option value="${esc(n.id)}">${esc(n.title)}</option>`).join('')}</select>` : ''}<input type="text" id="at" placeholder="${a.ph}" maxlength="200" autocomplete="off" required>
${a.due ? '<label class="sub" for="ad">Due (optional)</label><input type="date" id="ad">' : ''}${a.when ? `<label class="sub" for="ad">Day</label><input type="date" id="ad" value="${today}" required><label class="sub" for="atm">Time (leave empty for all day)</label><input type="time" id="atm">` : ''}
<div class="err" id="ae"></div><div style="display:flex;gap:10px;margin-top:10px"><button type="button" class="btn ghost" id="no" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Next</button></div></form>`);
box.querySelector('#no').onclick = close; setTimeout(() => { const t = box.querySelector('#at'); if (t) t.focus(); }, 50);
box.querySelector('#af').onsubmit = e => { e.preventDefault(); const payload = { kind, text: box.querySelector('#at').value.trim(), note: a.lists ? box.querySelector('#al').value : undefined, due: a.due ? box.querySelector('#ad').value : undefined, date: a.when ? box.querySelector('#ad').value : undefined, time: a.when ? box.querySelector('#atm').value : undefined, tz: Intl.DateTimeFormat().resolvedOptions().timeZone }; if (payload.text) whoThen(a.title, payload, a.done); };
}
function whoThen(title, payload, doneMsg) {
const head = `<h3 style="margin:0 0 4px">${esc(title)}</h3><p class="sub" style="margin:0 0 6px">${esc(payload.text)}</p>`;
const send = async (p, pin) => { const r = await fetch('/api/board/add', { method: 'POST', body: JSON.stringify({ ...payload, uid: p.id, pin }) }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'That did not work'); close(); await load(); toast(doneMsg); };
const { box, close } = sheet(`${head}<p class="sub">Who are you?</p><div class="tiles" style="justify-content:center">${data.people.map(p => `<button class="tile" data-id="${esc(p.id)}">${avatar(p)}<b>${esc(p.name.split(' ')[0])}</b><small>${p.kid ? '<span class="kid">KID</span>' : ''}</small></button>`).join('')}</div><div class="err" id="e"></div><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Cancel</button></p>`);
box.querySelector('#no').onclick = close;
box.querySelectorAll('.tile').forEach(t => t.onclick = () => { const p = data.people.find(x => x.id === t.dataset.id); if (p.kid && !p.pin) return send(p).catch(e => { box.querySelector('#e').textContent = e.message; }); if (!p.pin) { box.querySelector('#e').textContent = p.name + ' has not set a PIN yet. Set one in Settings on your own device.'; return; }
let pin = ''; box.innerHTML = `${head}<p class="sub">${esc(p.name)}, enter your PIN</p><div class="dots" id="dots"></div><div class="err" id="e"></div><div class="pad">${[1, 2, 3, 4, 5, 6, 7, 8, 9, '⌫', 0, '✓'].map(k => `<button data-k="${k}">${k}</button>`).join('')}</div><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Cancel</button></p>`;
const paint = () => { box.querySelector('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => `<i class="${i < pin.length ? 'on' : ''}"></i>`).join(''); }; paint(); box.querySelector('#no').onclick = close;
box.querySelectorAll('.pad button').forEach(b => b.onclick = async () => { const k = b.dataset.k; box.querySelector('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k === '✓') { try { await send(p, pin); return; } catch (e) { box.querySelector('#e').textContent = e.message; pin = ''; } } else if (pin.length < 8) pin += k; paint(); }); });
}
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); }
+1 -1
View File
@@ -27,7 +27,7 @@ const evStore = []; // events currently on screen, so a tap can open the right o
const evRow = e => { const i = evStore.push(e) - 1;
if (e.task) return `<div class="row it"><button class="ibtn tick" data-tt="${i}" aria-label="Mark done">${icon('check')}</button><button class="rmain" data-ei="${i}" style="text-align:left"><span class="grow"><b>${esc(e.title)}</b><small>Task due${e.t.repeat && e.t.repeat !== 'none' ? ' · repeats' : ''} · ${esc(e.cal)}</small></span></button></div>`;
return `<button class="row" data-ei="${i}" style="text-align:left"><span class="dot" style="background:${esc(e.color)}"></span><span class="grow"><b>${esc(e.title)}</b>
<small>${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.series ? ' · repeats' : ''}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; };
<small>${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.by ? ' · added by ' + esc(e.by) : ''}${e.series ? ' · repeats' : ''}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; };
document.addEventListener('click', e => {
const tk = e.target.closest('[data-tt]');
if (tk) { const ev = evStore[+tk.dataset.tt]; tk.disabled = true; jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: ev.t.list, id: ev.t.id, href: ev.t.href, done: true }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); route(); }).catch(err => { tk.disabled = false; toast(err.message); }); return; }
+1 -1
View File
@@ -54,7 +54,7 @@ async function ntEditor(id) {
v.innerHTML = `<div class="wrap ntedit" style="--nc:${NT_COLORS[n.color] || 'var(--line)'}"><div class="ptop"><a class="btn ghost" href="#/notes">${icon('back')}Notes</a><span class="pspace grow"></span><button class="ibtn" id="ntpin" aria-label="${n.pinned ? 'Unpin' : 'Pin'}" title="${n.pinned ? 'Unpin' : 'Pin to the top'}" style="${n.pinned ? 'color:var(--accent)' : ''}">${icon('bookmark')}</button><button class="ibtn" id="ntmore" aria-label="More">${icon('more')}</button></div>
<input id="ntt" class="nttitle" type="text" value="${esc(n.title)}" placeholder="${list ? 'Checklist' : 'Title'}" maxlength="120" autocomplete="off">
<p class="sub" style="margin:4px 0 12px"><a href="#" id="ntsh" style="color:var(--accent)">${esc(ntWho(n))}</a>${n.mine ? '' : ' · by ' + esc(n.ownerName)}${n.updatedByName ? ' · last change by ' + esc(n.updatedByName) + ', ' + esc(sgTime(n.updated)) : ''}</p>
${list ? `<div class="ntlist">${n.items.map(i => `<div class="ntitem ${i.done ? 'done' : ''}" data-i="${i.id}"><input type="checkbox" ${i.done ? 'checked' : ''} aria-label="Done"><span class="nttext" contenteditable="plaintext-only" spellcheck="false">${esc(i.text)}</span>${i.done && i.byName ? `<small>${esc(i.byName)}</small>` : ''}<button class="ibtn" data-x aria-label="Remove">${icon('x')}</button></div>`).join('')}</div>
${list ? `<div class="ntlist">${n.items.map(i => `<div class="ntitem ${i.done ? 'done' : ''}" data-i="${i.id}"><input type="checkbox" ${i.done ? 'checked' : ''} aria-label="Done"><span class="nttext" contenteditable="plaintext-only" spellcheck="false">${esc(i.text)}</span>${i.done && i.byName ? `<small>${esc(i.byName)}</small>` : i.addedBy && n.share !== 'me' ? `<small>${esc(i.addedBy)}</small>` : ''}<button class="ibtn" data-x aria-label="Remove">${icon('x')}</button></div>`).join('')}</div>
<form id="ntadd" class="bar" style="margin:8px 0;gap:8px"><input type="text" id="ntnewitem" placeholder="Add an item" maxlength="300" autocomplete="off" style="flex:1"><button class="btn">Add</button></form>
<div class="bar" style="justify-content:space-between"><small class="sub">${ntDone(n)} of ${n.items.length} done</small>${ntDone(n) ? '<button class="btn ghost" id="ntclr">Clear the done ones</button>' : ''}</div>`
: `<textarea id="ntb" class="ntbody" placeholder="Write here" maxlength="50000">${esc(n.body)}</textarea>`}</div>`;
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v77';
const CACHE = 'hub-v78';
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'];
+1 -1
View File
@@ -48,7 +48,7 @@ async function loadTaskList(cur, list) {
const done = tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
const REPEAT_LABEL = { daily: 'Daily', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', yearly: 'Yearly' };
const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : '';
const extra = [all && t._ln ? t._ln : '', rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · ');
const extra = [all && t._ln ? t._ln : '', t.by && t.list === 'local:family' ? 'Added by ' + t.by : '', rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · ');
return `<div class="row it${t.done ? ' donetask' : ''}${sub ? ' subtask' : ''}"><button class="ibtn tick" data-t="${i}" aria-label="${t.done ? 'Mark not done' : 'Mark done'}">${icon('check')}</button>
<button class="rmain" data-e="${i}"><span class="grow"><b>${esc(t.title)}</b>${du || t.notes || extra ? `<small>${du ? `<span style="color:${du.color}">${esc(du.text)}</span>` : ''}${du && (extra || t.notes) ? ' · ' : ''}${esc(extra)}${extra && t.notes ? ' · ' : ''}${esc((t.notes || '').split('\n')[0])}</small>` : ''}</span></button></div>`; };
const showDone = store.get('task_show_done', false);
+9 -3
View File
@@ -48,7 +48,13 @@ async function shop(p, opt) {
let last; for (const b of ['/api/households/shopping', '/api/groups/shopping']) { try { const r = await mealie(b + p, opt); shopBase = b; return r; } catch (e) { last = e; if (e.status !== 404) throw e; } }
throw last;
}
const itemOut = i => ({ id: i.id, text: String(i.display || i.note || (i.food && i.food.name) || '').trim(), checked: !!i.checked, label: (i.label && i.label.name) || '', position: i.position || 0 });
// who put each item on the shopping list (Mealie does not keep that): remembered here by the item's words
const whoFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'shopping-by.json');
let whoMap = null; const whoAll = () => whoMap || (whoMap = (() => { try { return JSON.parse(fs.readFileSync(whoFile(), 'utf8')); } catch { return {}; } })());
const whoKey = t => String(t || '').toLowerCase().replace(/\s+/g, ' ').trim();
const rememberWho = (text, name) => { const m = whoAll(), k = whoKey(text); m[k] = { by: name, at: Date.now() }; const keys = Object.keys(m); if (keys.length > 500) for (const o of keys.sort((a, b) => m[a].at - m[b].at).slice(0, keys.length - 400)) delete m[o]; try { fs.mkdirSync(path.dirname(whoFile()), { recursive: true }); require('./atomic')(whoFile(), JSON.stringify(m)); } catch {} };
const itemOut0 = i => ({ id: i.id, text: String(i.display || i.note || (i.food && i.food.name) || '').trim(), checked: !!i.checked, label: (i.label && i.label.name) || '', position: i.position || 0 });
const itemOut = i => { const o = itemOut0(i); o.by = (whoAll()[whoKey(o.text)] || {}).by || ''; return o; };
async function mainList() { // the first shopping list (the one called "Shopping List" if there is more than one)
const l = await shop('/lists?perPage=50'), items = Array.isArray(l) ? l : l.items || [];
let pick = items.find(x => /shopping/i.test(x.name || '')) || items[0];
@@ -128,7 +134,7 @@ async function handle(req, res) {
}
if (p === '/shopping/add' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').trim().slice(0, 200); if (!text) throw fail(400, 'Type what to buy');
const list = await mainList(); await shop('/items', { method: 'POST', body: { shoppingListId: list.id, note: text, quantity: 1, checked: false } }); await sync(); return json(res, 200, { ok: true });
const list = await mainList(); await shop('/items', { method: 'POST', body: { shoppingListId: list.id, note: text, quantity: 1, checked: false } }); rememberWho(text, users.current().name); await sync(); return json(res, 200, { ok: true });
}
if (p === '/shopping/check' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'); if (!idOk(b.id)) throw fail(400, 'Bad item');
@@ -148,5 +154,5 @@ async function handle(req, res) {
/** Recipes in Mealie that match the words (none when Mealie is not set up or does not answer). */
async function search(q) { if (!enabled()) return []; try { const j = await mealie(`/api/recipes?page=1&perPage=6&orderBy=name&orderDirection=asc&search=${encodeURIComponent(q)}`, { timeout: 6000 }); return (j.items || []).map(card); } catch { return []; } }
/** The open items on the shopping list, for the family board ([] when Mealie is not set up or does not answer). */
async function shoppingOpen() { if (!enabled()) return []; try { return (await readList()).items.filter(i => !i.checked).slice(0, 12).map(i => ({ id: i.id, text: i.text })); } catch { return []; } }
async function shoppingOpen() { if (!enabled()) return []; try { return (await readList()).items.filter(i => !i.checked).slice(0, 12).map(i => ({ id: i.id, text: i.text, by: i.by })); } catch { return []; } }
module.exports = { shoppingOpen, handle, enabled, minutes, full, ingredientText, search };
+4 -4
View File
@@ -107,7 +107,7 @@ async function handle(req, res) {
const list = q.get('list');
if (isLocal(list)) {
const raw = list.slice(6);
return json(res, 200, load().tasks.filter(t => t.list === raw).map(t => ({ id: t.id, list, title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, completedAt: t.completedAt || null, repeat: t.repeat || 'none', parent: t.parent || '' })));
return json(res, 200, load().tasks.filter(t => t.list === raw).map(t => ({ id: t.id, list, title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, completedAt: t.completedAt || null, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' })));
}
if (caldav.todoLists().some(l => l.id === list)) {
const rows = await caldav.listTodoRaw(list), tasks = [];
@@ -132,7 +132,7 @@ async function handle(req, res) {
if (b.moveTo && isLocal(b.moveTo) && !t.parent) { const to = b.moveTo.slice(6); d.tasks.filter(x => x.parent === t.id).forEach(x => { x.list = to; }); t.list = to; }
save(d); return json(res, 200, { ok: true, rolled });
}
d.tasks.push({ id: crypto.randomUUID(), list: raw, title: f.title.trim(), notes: f.notes || '', due: f.due || '', done: !!f.done, completedAt: f.done ? Date.now() : null, repeat: cleanRepeat(f.repeat), parent: validParent(d, raw, f.parent, null) || undefined, created: Date.now(), updated: Date.now() });
d.tasks.push({ id: crypto.randomUUID(), list: raw, title: f.title.trim(), notes: f.notes || '', due: f.due || '', done: !!f.done, completedAt: f.done ? Date.now() : null, repeat: cleanRepeat(f.repeat), parent: validParent(d, raw, f.parent, null) || undefined, created: Date.now(), updated: Date.now(), by: users.current().name, byId: users.current().id }); // (who added it, so a shared list shows whose it was)
save(d); return json(res, 200, { ok: true });
}
if (b.href) { // edit an iCloud task: patch the latest copy so alarms/priority/etc. survive
@@ -182,11 +182,11 @@ async function handle(req, res) {
/** Every task from every list (NE Hub and iCloud), with its list name. */
async function allTasks() {
const d = load(), out = [];
for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '' }); }
for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' }); }
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch {} }
return out;
}
/** Open tasks on the shared Family list, soonest first, for the family board. */
const familyOpen = () => { const t = readJ(famFile(), { tasks: [] }).tasks || []; return t.filter(x => !x.done && !x.parent).sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title)).slice(0, 8).map(x => ({ id: x.id, title: x.title, due: x.due || '' })); };
const familyOpen = () => { const t = readJ(famFile(), { tasks: [] }).tasks || []; return t.filter(x => !x.done && !x.parent).sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title)).slice(0, 8).map(x => ({ id: x.id, title: x.title, due: x.due || '', by: x.by || '' })); };
module.exports = { familyOpen, handle, parseTodos, allTasks, nextDue, stepDate };
+10 -3
View File
@@ -1,12 +1,12 @@
// Family devices: making one, the board, picking a profile (kids tap, others use a PIN), short sessions, and turning one off.
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-board-')); process.env.COOKIE_SECRET = 'x'.repeat(40);
const users = require('../users'), auth = require('../auth'), board = require('../board'), chores = require('../chores');
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), kid = users.create({ name: 'Kai', role: 'user', tabs: ['chat', 'chores'], handle: 'Yg' }), bob = users.create({ name: 'Bob', role: 'user', tabs: ['chat', 'chores'], handle: 'Yw' });
const users = require('../users'), auth = require('../auth'), board = require('../board'), chores = require('../chores'), notes = require('../notes'), tasks = require('../tasks'), cal = require('../cal');
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), kid = users.create({ name: 'Kai', role: 'user', tabs: ['chat', 'chores', 'tasks', 'notes', 'calendar'], handle: 'Yg' }), bob = users.create({ name: 'Bob', role: 'user', tabs: ['chat', 'chores'], handle: 'Yw' });
users.update(kid.id, { kid: { on: true } });
const mac = v => crypto.createHmac('sha256', auth.secret).update(v).digest('base64url');
const session = u => { const exp = Date.now() + 864e5; return `hub_session=${u.id}.${u.epoch}.${exp}.${mac(`s${u.id}.${u.epoch}.${exp}`)}`; };
const hub = http.createServer((req, res) => req.url.startsWith('/api/chores') ? users.runAs(ann, () => chores.handle(req, res)) : board.handle(req, res)).listen(0, async () => {
const hub = http.createServer((req, res) => req.url.startsWith('/api/chores') ? users.runAs(ann, () => chores.handle(req, res)) : req.url.startsWith('/api/notes') ? users.runAs(ann, () => notes.handle(req, res)) : req.url.startsWith('/api/tasks') ? users.runAs(ann, () => tasks.handle(req, res)) : req.url.startsWith('/api/cal') ? users.runAs(ann, () => cal.handle(req, res)) : board.handle(req, res)).listen(0, async () => {
const B = 'http://127.0.0.1:' + hub.address().port; let jar = {};
const call = async (p, o = {}, cookie) => { const r = await fetch(B + p, { ...o, headers: { ...(cookie ? { cookie } : {}), ...(o.headers || {}) } }); return { status: r.status, json: await r.json().catch(() => ({})), set: r.headers.getSetCookie ? r.headers.getSetCookie() : [] }; };
const post = (p, b, cookie) => call(p, { method: 'POST', body: JSON.stringify(b || {}) }, cookie);
@@ -24,6 +24,13 @@ const hub = http.createServer((req, res) => req.url.startsWith('/api/chores') ?
assert.equal((await act(kid.id, 'claim')).status, 200, 'Kai takes it with one tap'); cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.claimed, true); assert.equal(cb.userName, 'Kai');
assert.equal((await act(bob.id, 'done')).status, 403, 'Bob has no PIN, so he cannot act as himself yet'); assert.equal((await act(kid.id, 'done')).status, 200); cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.done, true); assert.equal(cb.doneBy, 'Kai');
assert.equal((await act(kid.id, 'undo')).status, 200); assert.equal((await post('/api/board/chore', { uid: kid.id, chore: ch.id, action: 'done' }, '')).status, 403, 'only a family device can do this'); assert.equal((await act(kid.id, 'nope')).status, 400);
const 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');
assert.equal((await add({ kind: 'event', text: 'Grandma visits', date: day0 })).status, 200, 'or an all-day one');
const nt = (await post('/api/notes', { type: 'list', title: 'Packing', items: ['Hats'], share: 'family' })).json; assert.equal((await add({ kind: 'item', text: 'Sunscreen', note: nt.id })).status, 200); assert.equal((await call('/api/board?day=' + day0, {}, dev)).json.notes.find(n => n.id === nt.id).total, 2, 'the item is on the family list'); assert.equal((await add({ kind: 'item', text: 'x', note: 'nope' })).status, 404);
const bd = (await call('/api/board?day=' + day0, {}, dev)).json; assert.equal(bd.tasks.find(t => t.title === 'Water the plants').by, 'Kai', 'the task says who added it'); assert.equal(bd.events.find(e => e.title === 'Movie night').by, 'Kai', 'and so does the event'); assert.equal((await call('/api/notes')).json.notes.find(n => n.id === nt.id).items.find(i => i.text === 'Sunscreen').addedBy, 'Kai', 'and the list item');
assert.equal((await add({ kind: 'shopping', text: 'Milk' })).status, 403, 'no Recipes tab: not allowed'); assert.equal((await add({ kind: 'task', text: '' })).status, 400); assert.equal((await add({ kind: 'bogus', text: 'x' })).status, 400); assert.equal((await post('/api/board/add', { uid: kid.id, kind: 'task', text: 'x' }, '')).status, 403, 'only on a family device');
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');
assert.equal((await post('/api/device/pin', { pin: '12' }, session(bob))).status, 400, 'a PIN is 4 to 8 digits'); assert.equal((await post('/api/device/pin', { pin: '2468' }, session(bob))).status, 200);
assert.equal((await post('/api/board/switch', { uid: bob.id, pin: '1111' }, dev)).status, 401); assert.equal((await post('/api/board/switch', { uid: bob.id, pin: '2468' }, dev)).status, 200);