Files
hub/public/board.js
T
nmemmertandClaude Sonnet 5.5 3c721ef6a5
build-container / build (push) Successful in 4m43s
Family board: per-person chore columns, touch scrolling in every box, no list caps
- Chores box spans the width with a column per person (fits five or more)
- Each box and each person's chore column scrolls by touch on its own, with no scroll bars
- Shopping, tasks and shared notes show everything (they scroll)
- Card width no longer capped by the app's shared card style

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

103 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ---- Family board: what a family device shows before anyone signs in, and the "who is this?" step. Only the family's shared things are shown here.
const $ = s => document.querySelector(s);
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const ymd = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).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, add, cls) => `<section class="card ${cls || ''}"><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('');
}
const none = t => `<p class="none">${t}</p>`;
function draw() {
const t0 = ymd(new Date()), t1 = ymd(new Date(Date.now() + 864e5)), d = data;
$('#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)}${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 = 6, chores = (pool.length ? `<button class="pill pool" id="poolall" style="width:100%;justify-content:space-between;font-size:17px"><span>Pick a chore: ${pool.length} up for grabs</span><span>See all ›</span></button>` : '') + '<div class="chcols">' + 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 class="chl">${left.map(c => pill(c)).join('')}${left.length ? '' : '<small class="sub">All done 🎉</small>'}</div></div>`; }).join('') + '</div>';
$('#grid').innerHTML = card('Chores today', chores || none('No chores today'), '', 'wide')
+ card('Today and tomorrow', day('Today', t0) + day('Tomorrow', t1), 'event')
+ card('This week', weekCard(d.week)) + card('Shopping list', d.shopping.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.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.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() { 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) {
if (!p) return;
const ov = $('#ov'), box = $('#ovb'); ov.hidden = false;
const close = () => { ov.hidden = true; box.innerHTML = ''; };
const go = async pin => { const r = await fetch('/api/board/switch', { method: 'POST', body: JSON.stringify({ uid: p.id, pin }) }), j = await r.json().catch(() => ({})); if (r.ok) return location.replace('/'); throw new Error(j.error || 'Could not sign in'); };
const head = `<div class="tile" style="margin:0 auto;border:0;background:none;cursor:default"><span class="av" style="background:${p.color}">${esc((p.name.trim()[0] || '?').toUpperCase())}</span><b>${esc(p.name)}</b>${p.kid ? '<span class="kid">KID</span>' : ''}</div>`;
if (p.kid && !p.pin) { box.innerHTML = head + `<p>Sign in as <b>${esc(p.name)}</b>?</p><button class="btn" id="yes" style="width:100%;min-height:52px;justify-content:center">Yes, that's me</button><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Not me</button></p><div class="err" id="e"></div>`; $('#no').onclick = close; $('#yes').onclick = () => go().catch(e => $('#e').textContent = e.message); return; }
if (!p.pin) { box.innerHTML = head + `<p>${esc(p.name)} has not set a PIN yet. Set one in Settings on your own device, or <a href="/login" style="color:var(--accent)">sign in with your passkey</a>.</p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Close</button>`; $('#no').onclick = close; return; }
let pin = ''; const paint = () => { $('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => `<i class="${i < pin.length ? 'on' : ''}"></i>`).join(''); };
box.innerHTML = head + `<p class="sub">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>`;
paint(); $('#no').onclick = close;
box.querySelectorAll('.pad button').forEach(b => b.onclick = async () => { const k = b.dataset.k; $('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k === '✓') { try { await go(pin); } catch (e) { $('#e').textContent = e.message; pin = ''; } } else if (pin.length < 8) pin += k; paint(); });
}
clock(); setInterval(clock, 15000); load(); setInterval(() => { if (!document.hidden && $('#ov').hidden) load(); }, 60000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
// ---- tap a chore: (if it is up for grabs, choose what you are doing), pick your name, PIN if you are not a child, then it happens as you
const avatar = p => `<span class="av" style="background:${p.color}">${esc((p.name.trim()[0] || '?').toUpperCase())}</span>`;
function sheet(html) { const ov = $('#ov'); ov.hidden = false; $('#ovb').innerHTML = html; return { box: $('#ovb'), close: () => { ov.hidden = true; $('#ovb').innerHTML = ''; } }; }
function choreTap(c) {
if (!c) return; const title = `<h3 style="margin:0 0 4px">${esc(c.title)}</h3>`;
const who = action => { // step: which person
const list = c.user ? data.people.filter(p => p.id === c.user) : data.people, { box, close } = sheet(`${title}<p class="sub">${c.user && !c.done ? 'This one is ' + esc(c.userName) + "'s. Tap your name" : 'Who are you?'}</p><div class="tiles" style="justify-content:center">${list.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><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 = () => proof(data.people.find(p => p.id === t.dataset.id), action, close));
};
const proof = (p, action, close) => { // step: PIN if needed, then do it
const send = async pin => { const r = await fetch('/api/board/chore', { method: 'POST', body: JSON.stringify({ uid: p.id, pin, chore: c.chore, action, date: ymd(new Date()) }) }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'That did not work'); close(); await load(); };
if (p.kid && !p.pin) return send().catch(e => { const x = sheet(`${title}<p class="err">${esc(e.message)}</p><button class="btn ghost" id="no" style="width:100%;justify-content:center">OK</button>`); x.box.querySelector('#no').onclick = x.close; });
if (!p.pin) { const x = sheet(`${title}<p>${esc(p.name)} has not set a PIN yet. Set one in Settings on your own device.</p><button class="btn ghost" id="no" style="width:100%;justify-content:center">OK</button>`); x.box.querySelector('#no').onclick = x.close; return; }
let pin = ''; const { box } = sheet(`${title}<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(pin); return; } catch (e) { box.querySelector('#e').textContent = e.message; pin = ''; } } else if (pin.length < 8) pin += k; paint(); });
};
if (c.done) return who('undo');
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); }