Files
hub/public/board.js
T
nmemmertandClaude Sonnet 5.5 eef1adaa73
build-container / build (push) Successful in 4m30s
Family devices: shared-screen family board with a who-is-this profile picker
- Admin can make a screen a family device; it opens on a family board
- Name tiles with KID labels; kids tap, others enter a PIN (lockout after 5 wrong tries)
- Name label with Switch user, auto-return to the board after 5 idle minutes
- Short 30-minute sessions on family devices
- Settings section for PIN and turning a device on or off
- Test, README and roadmap updated

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

43 lines
6.4 KiB
JavaScript

'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) => `<section class="card"><h2>${t}</h2>${body}</section>`;
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)}</span><small>${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</small></div>`).join('') || none('Nothing')); };
const per = {}; d.chores.forEach(c => { const k = c.userName; (per[k] = per[k] || []).push(c); });
const chores = Object.keys(per).map(k => { const l = per[k], dn = l.filter(c => c.done).length; return `<div class="ln" style="display:block"><div style="display:flex;justify-content:space-between"><b>${esc(k)}</b><small>${dn} of ${l.length}</small></div><div class="bar2"><i style="width:${Math.round(dn / l.length * 100)}%"></i></div><small>${esc(l.filter(c => !c.done).slice(0, 3).map(c => c.title).join(' · ') || 'All done 🎉')}</small></div>`; }).join('');
$('#grid').innerHTML = card('Today and tomorrow', day('Today', t0) + day('Tomorrow', t1))
+ card('Chores today', chores || none('No chores today'))
+ card('Shopping list', d.shopping.map(i => `<div class="ln"><span class="g">${esc(i.text)}</span></div>`).join('') || none('Nothing to buy'))
+ card('Family tasks', d.tasks.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.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'));
}
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(); });