'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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) => `${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('');
}
const none = t => `${t}
`;
function draw() {
const t0 = ymd(new Date()), t1 = ymd(new Date(Date.now() + 864e5)), d = data;
$('#annc').innerHTML = d.announcement ? `${esc(d.announcement.text)}
${esc(d.announcement.by)}
` : '';
$('#tiles').innerHTML = d.people.map(p => `${esc((p.name.trim()[0] || '?').toUpperCase())} ${esc(p.name.split(' ')[0])} ${p.kid ? 'KID ' : ''} `).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.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) => `${esc(c.title)}${c.points ? ` ${c.points} pts ` : ''} `;
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 ? `See all ${pool.length} ` : ''}
` : '') + 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), '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)} ${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 => `${esc(c.title)} ${c.points ? `${c.points} pts ` : ''} `).join('')}
Close
`);
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 = `${esc((p.name.trim()[0] || '?').toUpperCase())} ${esc(p.name)} ${p.kid ? 'KID ' : ''}
`;
if (p.kid && !p.pin) { box.innerHTML = head + `Sign in as ${esc(p.name)} ?
Yes, that's me Not me
`; $('#no').onclick = close; $('#yes').onclick = () => go().catch(e => $('#e').textContent = e.message); return; }
if (!p.pin) { box.innerHTML = head + `${esc(p.name)} has not set a PIN yet. Set one in Settings on your own device, or sign in with your passkey .
Close `; $('#no').onclick = close; return; }
let pin = ''; const paint = () => { $('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => ` `).join(''); };
box.innerHTML = head + `Enter your PIN
${[1, 2, 3, 4, 5, 6, 7, 8, 9, '⌫', 0, '✓'].map(k => `${k} `).join('')}
Cancel
`;
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 => `${esc((p.name.trim()[0] || '?').toUpperCase())} `;
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 = `${esc(c.title)} `;
const who = action => { // step: which person
const list = c.user ? data.people.filter(p => p.id === c.user) : data.people, { box, close } = sheet(`${title}${c.user && !c.done ? 'This one is ' + esc(c.userName) + "'s. Tap your name" : 'Who are you?'}
${list.map(p => `${avatar(p)}${esc(p.name.split(' ')[0])} ${p.kid ? 'KID ' : ''} `).join('')}
Cancel
`);
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}${esc(e.message)}
OK `); x.box.querySelector('#no').onclick = x.close; });
if (!p.pin) { const x = sheet(`${title}${esc(p.name)} has not set a PIN yet. Set one in Settings on your own device.
OK `); x.box.querySelector('#no').onclick = x.close; return; }
let pin = ''; const { box } = sheet(`${title}${esc(p.name)}, enter your PIN
${[1, 2, 3, 4, 5, 6, 7, 8, 9, '⌫', 0, '✓'].map(k => `${k} `).join('')}
Cancel
`);
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(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}${c.points ? c.points + ' points' : 'Up for grabs'}
I did it Take it for later Cancel
`); 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} `);
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 => `${avatar(p)}${esc(p.name.split(' ')[0])} ${p.kid ? 'KID ' : ''} `).join('')}
Cancel
`);
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 => `${k} `).join('')}
Cancel
`;
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); }