Family board: greets the family by name, cheers on finished chores with confetti, and says what it takes to reach the goal
build-container / build (push) Successful in 4m21s
build-container / build (push) Successful in 4m21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6ba9992e55
commit
522bd64b46
5 files changed
+40
-7
No files matched your search
@@ -47,6 +47,7 @@
|
||||
body { padding: 18px; }
|
||||
.bd { max-width: 1280px; margin: 0 auto; }
|
||||
.bdtop { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; } .bdl { flex: none; } .pickrow { flex: 1 1 320px; min-width: 0; display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; } .bdclock { flex: none; margin-left: auto; }
|
||||
.cheer { margin: 6px 0 4px; padding: 10px 14px; border-radius: 14px; background: #2f9e6e; color: #fff; font-weight: 700; font-size: 18px; text-align: center; animation: cpop .35s ease-out; } @keyframes cpop { from { transform: scale(.85); opacity: 0; } to { transform: none; opacity: 1; } } @keyframes cfall { to { transform: translateY(105vh) rotate(540deg); opacity: .9; } }
|
||||
.chrow { display: flex; align-items: center; gap: 8px; padding: 9px 0; border-bottom: 1px solid var(--line); text-align: left; } .chrow .g { flex: 1; min-width: 0; font-size: 18px; } .chrow.dn .g { text-decoration: line-through; opacity: .6; } .chrow .btn { min-height: 44px; padding: 0 14px; }
|
||||
.pickrow .pk { display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-radius: 18px; background: var(--panel); border: 2px solid var(--line); color: var(--text); font: inherit; text-align: left; cursor: pointer; } .pickrow .pk.has { border-color: var(--accent); } .pickrow .pk:active { transform: scale(.97); } .pickrow .pk .av { width: 46px; height: 46px; border-radius: 50%; color: #fff; display: grid; place-items: center; font-size: 21px; font-weight: 700; flex: none; } .pickrow .pk b { display: block; font-size: 18px; } .pickrow .pk small { color: var(--muted); font-size: 13px; white-space: nowrap; }
|
||||
.bdtop h1 { margin: 0; font-size: 34px; } .bdclock { font-size: 40px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
+21
-5
@@ -5,7 +5,7 @@ const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&a
|
||||
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' }); }
|
||||
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') + ', ' + ((data && data.familyName) || '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');
|
||||
@@ -47,7 +47,7 @@ function poolAll() {
|
||||
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 {} }
|
||||
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(); clock(); } catch {} }
|
||||
|
||||
function pick(p) {
|
||||
if (!p) return;
|
||||
@@ -115,15 +115,31 @@ function whoThen(title, payload, doneMsg) {
|
||||
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
|
||||
const CHEER = ['Great job, NAME! 🎉', 'Nailed it, NAME! 💪', 'You are on a roll, NAME! 🔥', 'Awesome work, NAME! ⭐', 'Look at you go, NAME! 🚀', 'Super helper, NAME! 🙌', 'Way to go, NAME! 👏', 'That is how it is done, NAME! 🌟'];
|
||||
/** A burst of confetti over the screen (skipped for anyone who asks for less motion). */
|
||||
function confetti(big) {
|
||||
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return; const box = document.createElement('div'); box.style.cssText = 'position:fixed;inset:0;pointer-events:none;overflow:hidden;z-index:200';
|
||||
const bits = big ? ['🎉', '🏆', '⭐', '🎊', '✨', '🌟'] : ['🎉', '⭐', '✨', '🌟']; for (let i = 0; i < (big ? 40 : 18); i++) { const e = document.createElement('span'); e.textContent = bits[i % bits.length]; e.style.cssText = `position:absolute;top:-30px;left:${Math.random() * 100}%;font-size:${18 + Math.random() * 22}px;animation:cfall ${1.4 + Math.random() * 1.4}s ease-in ${Math.random() * .5}s forwards`; box.appendChild(e); }
|
||||
document.body.appendChild(box); setTimeout(() => box.remove(), 3500);
|
||||
}
|
||||
/** What it takes to reach the goal, in plain words: which chores would do it today, or how far today's chores get them. */
|
||||
function goalHint(tier, points, chores) {
|
||||
const need = tier.points - points, ds = Math.floor((Date.now() - new Date(data.week.from + 'T12:00:00').getTime()) / 864e5), left = 6 - ds; /* days left after today */
|
||||
const list = chores.filter(c => c.points > 0).sort((a, b) => b.points - a.points), total = list.reduce((n, c) => n + c.points, 0), tail = left <= 0 ? ' Last day this week!' : left === 1 ? ' One more day after today.' : ' ' + left + ' more days after today.';
|
||||
if (!list.length) return need + ' more points to go. New chores tomorrow can get you there.' + tail;
|
||||
if (total >= need) { const pick = []; let sum = 0; for (const c of list) { if (sum >= need) break; pick.push(c); sum += c.points; } const names = pick.slice(0, 3).map(c => c.title).join(', ') + (pick.length > 3 ? ' and ' + (pick.length - 3) + ' more' : ''); return '⭐ Do ' + names + ' (' + sum + ' pts) and you reach it today!'; }
|
||||
return 'Do all ' + list.length + ' chore' + (list.length > 1 ? 's' : '') + ' today (+' + total + ' pts) and you will be only ' + (need - total) + ' away. Tomorrow gets you there!' + tail;
|
||||
}
|
||||
function choreHub(p, pin0) {
|
||||
if (!p) return; let pin = pin0 || '';
|
||||
if (!p) return; let pin = pin0 || '', cheer = '';
|
||||
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 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; } const wasPts = ((data.week && data.week.people.find(x => x.id === p.id)) || { points: 0 }).points; await load(); cheer = ''; if (action === 'done') { const w = (data.week && data.week.people.find(x => x.id === p.id)) || { points: wasPts, goal: null }, tier = data.week && data.week.tiers.find(t => t.id === w.goal), hit = tier && wasPts < tier.points && w.points >= tier.points, rest = data.chores.filter(x => x.user === p.id && !x.done).length;
|
||||
cheer = hit ? '🏆 You reached your goal, ' + p.name.split(' ')[0] + '! Tell a parent!' : (CHEER[Math.floor(Math.random() * CHEER.length)].replace('NAME', p.name.split(' ')[0])) + (c.points ? ' +' + c.points + ' pts' : '') + (!rest ? ' ⭐ All your chores are done!' : ''); confetti(hit); } else if (action === 'claim') cheer = 'Good pick! It is yours. 👍'; 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">`
|
||||
box.innerHTML = head + (cheer ? `<div class="cheer">${esc(cheer)}</div>` : '') + (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' : esc(goalHint(tier, me.points, todo.concat(pool)))}</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('') : '')
|
||||
|
||||
@@ -11,8 +11,11 @@ async function famSettings(box) {
|
||||
box.innerHTML = `<h2>You</h2><div class="list">${can('chat') ? famRow('fs-chat', 'chat', 'Chat', 'Quiet hours for phone alerts' + (admin ? ', and the family announcement' : '')) : ''}</div><div id="fs-dev"></div>
|
||||
${admin && can('chores') ? `<h2>Chores</h2><div class="list">${famRow('fs-chores', 'check', 'Chores and weekly rewards', 'Make chores, choose who does them, set the points and the reward tiers')}</div>` : ''}
|
||||
${admin ? '<h2>Children</h2><div id="fs-req"></div><p class="sub" style="margin:8px 0">What each child did this week, and Plex.</p><div class="list" id="fs-kids"><div class="row"><span class="grow"><small>Loading…</small></span></div></div><div class="list" style="margin-top:10px">' + famRow('fs-words', 'search', 'Blocked search words', 'A child\'s search only uses Simple English Wikipedia and a built-in list of blocked words. Add your own words here.') + '</div>' : ''}
|
||||
${admin ? '<h2>Family board</h2><p class="sub" style="margin:0 0 8px">Who has a name tile on the shared family screen and shows in its chore progress.</p><div class="list" id="fs-board"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>' : ''}`;
|
||||
${admin ? '<h2>Family board</h2><div class="list" style="margin-bottom:10px">' + famRow('fs-fname', 'people', 'Family name', 'What the board says: Good morning, ...') + '</div><p class="sub" style="margin:0 0 8px">Who has a name tile on the shared family screen and shows in its chore progress.</p><div class="list" id="fs-board"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>' : ''}`;
|
||||
const c = box.querySelector('#fs-chat'); if (c) c.onclick = () => chSettings();
|
||||
const fn = box.querySelector('#fs-fname'); if (fn) fn.onclick = async () => { let r; try { r = await jfetch('/api/board/family'); } catch (e) { return toast(e.message); }
|
||||
const el = openSheet('Family name', 'The family board greets everyone with this. Leave it empty to use the last name most of you share.', `<form id="ff"><label for="fnn">Name</label><input type="text" id="fnn" maxlength="40" value="${esc(r.name)}" placeholder="${esc(r.shown)}" autocomplete="off"><div class="bar"><button type="button" class="btn ghost" id="ffx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
||||
el.querySelector('#ffx').onclick = closeSheet; el.querySelector('#ff').onsubmit = async e => { e.preventDefault(); try { const o = await jfetch('/api/board/family', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#fnn').value }) }); closeSheet(); toast('The board now says: Good morning, ' + o.shown); } catch (x) { toast(x.message); } }; };
|
||||
const k = box.querySelector('#fs-chores'); if (k) k.onclick = famManageChores;
|
||||
if (typeof devSettings === 'function') devSettings(box.querySelector('#fs-dev'));
|
||||
if (admin) { famRequests(box.querySelector('#fs-req')); famKids(box.querySelector('#fs-kids')); const w = box.querySelector('#fs-words'); if (w) w.onclick = famWords; }
|
||||
|
||||
Reference in new issue
Block a user