142 lines
28 KiB
JavaScript
142 lines
28 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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)), goal = mine.find(t => t.id === p.goal), top = Math.max(1, goal ? goal.points : 0, p.points); 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">${goal ? `<span style="position:absolute;top:0;left:${Math.min(100, Math.round(goal.points / top * 100))}%;width:2px;height:100%;background:var(--text);opacity:.5"></span>` : ''}<i style="width:${Math.round(p.points / top * 100)}%"></i></div>${goal ? `<small>${p.points >= goal.points ? '🎯 ' + esc(goal.title) + ' reached! 🎉' : '🎯 ' + (goal.points - p.points) + ' more for ' + esc(goal.title)}</small>` : mine.length ? `<button type="button" class="btn ghost" data-goalfor="${esc(p.id)}" style="margin-top:4px;min-height:40px">Choose a reward</button>` : ''}</div>`; }).join('');
|
||
}
|
||
// ---- Choose a reward to aim for: pick it from a pop-up, then PIN (unless a child with no PIN), and it is set as that person
|
||
function goalFlow(pid, back) {
|
||
const p = data.people.find(x => x.id === pid), w = data.week; if (!p || !w) return; const mine = w.tiers.filter(t => ((w.people.find(x => x.id === pid) || {}).tiers || []).includes(t.id));
|
||
const { box, close } = sheet(`<h3 style="margin:0 0 4px">${esc(p.name.split(' ')[0])}, what are you aiming for?</h3><p class="sub" style="margin:0 0 8px">Pick one reward for this week.</p><div class="list" style="text-align:left">${mine.map(t => `<button class="row" data-g="${esc(t.id)}" style="width:100%"><span class="grow"><b>${esc(t.title)}</b><small>${t.points} points in a week${t.minutes ? ' · +' + t.minutes + ' min screen time' : ''}</small></span></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;
|
||
const send = async (reward, pin) => { const r = await fetch('/api/board/goal', { method: 'POST', body: JSON.stringify({ uid: p.id, pin, reward, date: ymd(new Date()) }) }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'That did not work'); const wp = data.week && data.week.people.find(x => x.id === p.id); if (wp) wp.goal = reward || null; close(); await load(); if (back) back(pin); else toast('Goal chosen'); };
|
||
box.querySelectorAll('[data-g]').forEach(b => b.onclick = () => { const reward = b.dataset.g;
|
||
if (p.kid && !p.pin) return send(reward).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 = `<h3 style="margin:0 0 4px">${esc(p.name)}, enter your PIN</h3><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(k2 => k2.onclick = async () => { let k = k2.dataset.k; box.querySelector('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { try { await send(reward, pin); return; } catch (e) { box.querySelector('#e').textContent = e.message; pin = ''; } } paint(); }); });
|
||
}
|
||
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>` : '';
|
||
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 left = p => d.chores.filter(c => c.user === p.id && !c.done).length, grabs = d.chores.filter(c => c.claimable && !c.done).length, has = p => p.kid || d.chores.some(c => c.user === p.id);
|
||
$('#pickrow').innerHTML = d.people.map(p => `<button class="pk${has(p) ? ' has' : ''}" data-hub="${esc(p.id)}"><span class="av" style="background:${p.color}">${esc((p.name.trim()[0] || '?').toUpperCase())}</span><span><b>${esc(p.name.split(' ')[0])}</b><small>${has(p) ? (left(p) ? left(p) + ' to do' : 'All done') + (grabs ? ' · ' + grabs + ' to grab' : '') : 'Sign in'}</small></span></button>`).join('');
|
||
const pill = (c, cls) => `<button class="pill ${cls || ''}" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">${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.user || c.userName] = per[c.user || 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(l[0].userName)}</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="nt"><div class="ln" style="border:0;padding-bottom:2px"><span class="g"><b>${esc(n.title)}</b>${n.type === 'list' ? ` <small>${n.done} of ${n.total} done</small>` : ''}</span></div>${n.type === 'list' ? n.items.map(i => `<div class="ln nti ${i.done ? 'dn' : ''}"><span class="chk">${i.done ? '✓' : ''}</span><span class="g">${esc(i.text)}${i.by ? ` <small>${esc(i.by)}</small>` : ''}</span></div>`).join('') || '<p class="none">Empty</p>' : `<p class="ntbody">${esc(n.body)}</p>`}</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 && (c.user || '') === (b.dataset.u || ''))));
|
||
}
|
||
function wireChores() { document.querySelectorAll('[data-hub]').forEach(b => b.onclick = () => { const p = data.people.find(x => x.id === b.dataset.hub); if (p && (p.kid || data.chores.some(c => c.user === p.id))) choreHub(p); else pick(p); }); document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFlow(b.dataset.add)); document.querySelectorAll('[data-goalfor]').forEach(b => b.onclick = () => goalFlow(b.dataset.goalfor)); 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 && (c.user || '') === (b.dataset.u || '')))); }
|
||
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 () => { let k = b.dataset.k; $('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { try { await go(pin); return; } catch (e) { $('#e').textContent = e.message; pin = ''; } } paint(); });
|
||
}
|
||
clock(); setInterval(clock, 15000); load(); setInterval(() => { if (!document.hidden && $('#ov').hidden) load(); }, 15000); // (a chore someone just added shows up within a few seconds)
|
||
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 () => { let k = b.dataset.k; box.querySelector('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { try { await send(pin); return; } catch (e) { box.querySelector('#e').textContent = e.message; pin = ''; } } 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 () => { let k = b.dataset.k; box.querySelector('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { try { await send(p, pin); return; } catch (e) { box.querySelector('#e').textContent = e.message; pin = ''; } } 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); }
|
||
|
||
// ---- Pick your chores: tap your name, enter your PIN once (a child with no PIN goes straight in), then do, take or put back as many chores as you like
|
||
function choreHub(p, pin0) {
|
||
if (!p) return; let pin = pin0 || '';
|
||
const { box, close } = sheet(''), today = ymd(new Date()), head = `<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px"><span class="av" style="background:${p.color};width:44px;height:44px;border-radius:50%;color:#fff;display:grid;place-items:center;font-size:20px;font-weight:700;flex:none">${esc((p.name.trim()[0] || '?').toUpperCase())}</span><h3 style="margin:0;text-align:left">${esc(p.name.split(' ')[0])}'s chores</h3></div>`;
|
||
const act = async (c, action) => { const r = await fetch('/api/board/chore', { method: 'POST', body: JSON.stringify({ uid: p.id, pin, chore: c.chore, action, date: today }) }), j = await r.json().catch(() => ({})); if (!r.ok) { if (p.pin && /pin/i.test(j.error || '')) { pin = ''; return askPin(j.error); } box.querySelector('#e').textContent = j.error || 'That did not work'; return; } await load(); paint(); };
|
||
const paint = () => {
|
||
const mine = data.chores.filter(c => c.user === p.id), todo = mine.filter(c => !c.done), done = mine.filter(c => c.done), pool = data.chores.filter(c => c.claimable && !c.done), me = (data.week && data.week.people.find(x => x.id === p.id)) || { points: 0 };
|
||
const row = (c, btns, cls) => `<div class="chrow ${cls || ''}"><span class="g">${esc(c.title)}${c.points ? ` <small class="sub">${c.points} pts</small>` : ''}</span>${btns}</div>`, h = t => `<p class="sub" style="margin:12px 0 2px;text-align:left"><b>${t}</b></p>`;
|
||
const tier = (data.week ? data.week.tiers : []).find(t => t.id === me.goal), pct = tier ? Math.min(100, Math.round(me.points / tier.points * 100)) : 0, got = tier && me.points >= tier.points;
|
||
box.innerHTML = head + (tier ? `<div style="text-align:left;margin:2px 0 4px"><div style="display:flex;justify-content:space-between;gap:10px;font-size:15px"><b>🎯 ${esc(tier.title)}</b><span class="sub">${me.points} / ${tier.points} pts</span></div><div class="bar2" style="height:20px;margin:6px 0 4px;border-radius:10px"><i style="width:${pct}%;border-radius:10px${got ? ';background:#2f9e6e' : ''}"></i></div><small class="sub">${got ? 'You did it! 🎉 Tell a parent' : (tier.points - me.points) + ' more points to go'}</small></div>` : `<p class="sub" style="margin:0;text-align:left">${me.points} points this week${(data.week ? data.week.tiers : []).some(t => ((data.week.people.find(x => x.id === p.id) || {}).tiers || []).includes(t.id)) ? ' · <a href="#" id="pgl" style="color:var(--accent)">Choose a reward to aim for</a>' : ''}</p>`) + `<div style="max-height:50dvh;overflow:auto">`
|
||
+ h('To do today') + (todo.map(c => row(c, `<button class="btn" data-a="done" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">Done ✓</button>${c.claimed ? `<button class="btn ghost" data-a="unclaim" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">Put back</button>` : ''}`)).join('') || '<p class="none" style="text-align:left">Nothing left. Nice! 🎉</p>')
|
||
+ (pool.length ? h('Up for grabs. Take one!') + pool.map(c => row(c, `<button class="btn ghost" data-a="claim" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">Take it</button><button class="btn" data-a="done" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">I did it</button>`)).join('') : '')
|
||
+ (done.length ? h('Finished') + done.map(c => row(c, `<button class="btn ghost" data-a="undo" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">Undo</button>`, 'dn')).join('') : '')
|
||
+ `</div><div class="err" id="e"></div><div style="display:flex;gap:10px;margin-top:10px"><button class="btn ghost" id="sgn" style="flex:1;justify-content:center">Open my account</button><button class="btn" id="no" style="flex:1;justify-content:center">Close</button></div>`;
|
||
const pg = box.querySelector('#pgl'); if (pg) pg.onclick = e => { e.preventDefault(); close(); goalFlow(p.id, pn => choreHub(p, pn)); };
|
||
box.querySelector('#no').onclick = close; box.querySelector('#sgn').onclick = () => { close(); pick(p); };
|
||
box.querySelectorAll('[data-a]').forEach(b => b.onclick = () => { b.disabled = true; const c = data.chores.find(x => x.chore === b.dataset.c && (x.user || '') === b.dataset.u); if (c) act(c, b.dataset.a); else { b.disabled = false; load().then(paint); } });
|
||
};
|
||
const askPin = msg => {
|
||
box.innerHTML = head + `<p class="sub">${esc(p.name)}, enter your PIN</p><div class="dots" id="dots"></div><div class="err" id="e">${esc(msg || '')}</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 dots = () => { box.querySelector('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => `<i class="${i < pin.length ? 'on' : ''}"></i>`).join(''); }; dots(); box.querySelector('#no').onclick = close;
|
||
box.querySelectorAll('.pad button').forEach(b => b.onclick = () => { let k = b.dataset.k; box.querySelector('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { if (pin) return paint(); } dots(); });
|
||
};
|
||
if (p.kid && !p.pin || pin0) return paint(); if (!p.pin) { box.innerHTML = head + `<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>`; box.querySelector('#no').onclick = close; return; } askPin();
|
||
}
|