From f87a218eea328271aa475d8eb1fd2fd9c5d61fc9 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:01:40 -0400 Subject: [PATCH] Family board: add to shopping, tasks, checklists and the Family calendar; record who added each - + 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 --- README.md | 2 +- board.js | 17 +++++++++++++++++ cal.js | 8 ++++---- notes.js | 2 +- public/board.html | 2 ++ public/board.js | 41 ++++++++++++++++++++++++++++++++++------- public/calendar.js | 2 +- public/notes-view.js | 2 +- public/sw.js | 2 +- public/tasks.js | 2 +- recipes.js | 12 +++++++++--- tasks.js | 8 ++++---- tools/board-test.js | 13 ++++++++++--- 13 files changed, 86 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 9b942f5..bacf3bd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/board.js b/board.js index 393b625..fb8d4c6 100644 --- a/board.js +++ b/board.js @@ -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'); diff --git a/cal.js b/cal.js index c7a9acb..7fa26e8 100644 --- a/cal.js +++ b/cal.js @@ -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 }; diff --git a/notes.js b/notes.js index f193e6a..aa3e315 100644 --- a/notes.js +++ b/notes.js @@ -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)); diff --git a/public/board.html b/public/board.html index 0920d33..dc06079 100644 --- a/public/board.html +++ b/public/board.html @@ -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 */ diff --git a/public/board.js b/public/board.js index dfb3725..5f906b7 100644 --- a/public/board.js +++ b/public/board.js @@ -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) => `

${t}

${body}
`; +const card = (t, body, add) => `

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

${body}
`; 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 `
${esc(p.name)}${p.points} pts · ${p.count} done
${mine.map(t => ``).join('')}
${got.length ? '🎁 ' + esc(got.map(t => t.title).join(', ')) : ''}${next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}
`; }).join(''); @@ -18,22 +18,22 @@ function draw() { $('#annc').innerHTML = d.announcement ? `
${esc(d.announcement.text)}
${esc(d.announcement.by)}
` : ''; $('#tiles').innerHTML = d.people.map(p => ``).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 `${label}` + (l.map(e => `
${esc(e.title)}${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
`).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 `${label}` + (l.map(e => `
${esc(e.title)}${e.by ? ` ${esc(e.by)}` : ''}${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
`).join('') || none('Nothing')); }; const pill = (c, cls) => ``; 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 ? `Up for grabs: tap one
${pool.slice(0, 6).map(c => pill(c, 'pool')).join('')}${pool.length > 6 ? `` : ''}
` : '') + Object.keys(per).map(k => { const l = per[k], dn = l.filter(c => c.done).length, left = l.filter(c => !c.done); return `
${esc(k)}${dn} of ${l.length}
${left.slice(0, SHOW).map(c => pill(c)).join('')}${left.length > SHOW ? `+${left.length - SHOW}` : ''}${left.length ? '' : 'All done 🎉'}
`; }).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 => `
${esc(i.text)}
`).join('') || none('Nothing to buy')) - + card('Family tasks', d.tasks.slice(0, 6).map(t => `
${esc(t.title)}${t.due === t0 ? 'Today' : esc(t.due)}
`).join('') || none('Nothing due')) - + card('Family notes and lists', d.notes.slice(0, 4).map(n => `
${esc(n.title)}${n.type === 'list' ? ` ${n.done} of ${n.total} done${n.next ? ' · next: ' + esc(n.next) : ''}` : ` ${esc(n.body)}`}
`).join('') || none('No shared notes')); + + card('This week', weekCard(d.week)) + card('Shopping list', d.shopping.slice(0, 10).map(i => `
${esc(i.text)}${i.by ? `${esc(i.by)}` : ''}
`).join('') || none('Nothing to buy'), 'shopping') + + card('Family tasks', d.tasks.slice(0, 6).map(t => `
${esc(t.title)}${t.by ? ` ${esc(t.by)}` : ''}${t.due === t0 ? 'Today' : esc(t.due)}
`).join('') || none('Nothing due'), 'task') + + card('Family notes and lists', d.notes.slice(0, 4).map(n => `
${esc(n.title)}${n.type === 'list' ? ` ${n.done} of ${n.total} done${n.next ? ' · next: ' + esc(n.next) : ''}` : ` ${esc(n.body)}`}
`).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(`

Up for grabs

${list.length} chores. Tap one to pick it

${list.map(c => ``).join('')}

`); 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}

${c.points ? c.points + ' points' : 'Up for grabs'}

`); 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(`

${a.title}

${a.lists ? `` : ''} + ${a.due ? '' : ''}${a.when ? `` : ''} +
`); + 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 = `

${esc(title)}

${esc(payload.text)}

`; + 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}

Who are you?

${data.people.map(p => ``).join('')}

`); + 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}

${esc(p.name)}, enter your PIN

${[1, 2, 3, 4, 5, 6, 7, 8, 9, '⌫', 0, '✓'].map(k => ``).join('')}

`; + const paint = () => { box.querySelector('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, 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); } diff --git a/public/calendar.js b/public/calendar.js index 31f997a..eb42954 100644 --- a/public/calendar.js +++ b/public/calendar.js @@ -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 `
`; return ``; }; + ${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' : ''}${e.editable ? icon('chev', 'chev') : ''}`; }; 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; } diff --git a/public/notes-view.js b/public/notes-view.js index ce687e8..2599020 100644 --- a/public/notes-view.js +++ b/public/notes-view.js @@ -54,7 +54,7 @@ async function ntEditor(id) { v.innerHTML = `
${icon('back')}Notes

${esc(ntWho(n))}${n.mine ? '' : ' · by ' + esc(n.ownerName)}${n.updatedByName ? ' · last change by ' + esc(n.updatedByName) + ', ' + esc(sgTime(n.updated)) : ''}

- ${list ? `
${n.items.map(i => `
${esc(i.text)}${i.done && i.byName ? `${esc(i.byName)}` : ''}
`).join('')}
+ ${list ? `
${n.items.map(i => `
${esc(i.text)}${i.done && i.byName ? `${esc(i.byName)}` : i.addedBy && n.share !== 'me' ? `${esc(i.addedBy)}` : ''}
`).join('')}
${ntDone(n)} of ${n.items.length} done${ntDone(n) ? '' : ''}
` : ``}
`; diff --git a/public/sw.js b/public/sw.js index 2127d2b..e3dddf4 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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']; diff --git a/public/tasks.js b/public/tasks.js index 22dba68..cdb236c 100644 --- a/public/tasks.js +++ b/public/tasks.js @@ -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 `
`; }; const showDone = store.get('task_show_done', false); diff --git a/recipes.js b/recipes.js index 3c11bfe..53ea4a5 100644 --- a/recipes.js +++ b/recipes.js @@ -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 }; diff --git a/tasks.js b/tasks.js index ae966b7..46ea511 100644 --- a/tasks.js +++ b/tasks.js @@ -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 }; diff --git a/tools/board-test.js b/tools/board-test.js index 14b8b82..ecc1e98 100644 --- a/tools/board-test.js +++ b/tools/board-test.js @@ -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);