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
@@ -32,6 +32,13 @@ const ymdOk = s => /^\d{4}-\d{2}-\d{2}$/.test(s || '');
|
|||||||
const isKid = u => !!(u.kid && u.kid.on);
|
const isKid = u => !!(u.kid && u.kid.on);
|
||||||
const palette = ['#e0742b', '#2f9e6e', '#4f7cff', '#8a5cf0', '#d9453a', '#2aa6a0', '#e5a00d'];
|
const palette = ['#e0742b', '#2f9e6e', '#4f7cff', '#8a5cf0', '#d9453a', '#2aa6a0', '#e5a00d'];
|
||||||
const onBoard = u => !u.disabled && u.board !== false; // the admin picks who is on the family board
|
const onBoard = u => !u.disabled && u.board !== false; // the admin picks who is on the family board
|
||||||
|
const NAME_FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'family-name.json');
|
||||||
|
const savedName = () => { try { return String(JSON.parse(fs.readFileSync(NAME_FILE(), 'utf8')).name || ''); } catch { return ''; } };
|
||||||
|
/** What the board calls the family: the name an admin set, or "<surname> Family" when two or more people share a last name, else "family". */
|
||||||
|
function familyName() {
|
||||||
|
const s = savedName(); if (s) return s; const last = {}; for (const u of users.all().filter(onBoard)) { const w = String(u.name || '').trim().split(/\s+/); if (w.length > 1) { const k = w[w.length - 1]; last[k] = (last[k] || 0) + 1; } }
|
||||||
|
const top = Object.entries(last).sort((a, b) => b[1] - a[1])[0]; return top && top[1] >= 2 ? top[0] + ' Family' : 'family';
|
||||||
|
}
|
||||||
const people = () => users.all().filter(onBoard).map((u, i) => ({ id: u.id, name: u.name, kid: isKid(u), pin: hasPin(u.id), pinLen: (db()[u.id] || {}).n || 0, color: palette[i % palette.length] }));
|
const people = () => users.all().filter(onBoard).map((u, i) => ({ id: u.id, name: u.name, kid: isKid(u), pin: hasPin(u.id), pinLen: (db()[u.id] || {}).n || 0, color: palette[i % palette.length] }));
|
||||||
|
|
||||||
/** Check it is really this person: a child with no PIN just taps their name; everyone else needs their PIN. */
|
/** Check it is really this person: a child with no PIN just taps their name; everyone else needs their PIN. */
|
||||||
@@ -48,7 +55,7 @@ function eventFields(title, b) {
|
|||||||
async function board(day) {
|
async function board(day) {
|
||||||
const t1 = chores.addDays(day, 1), from = new Date(day + 'T00:00:00').getTime() - 864e5, to = new Date(t1 + 'T23:59:59').getTime() + 864e5;
|
const t1 = chores.addDays(day, 1), from = new Date(day + 'T00:00:00').getTime() - 864e5, to = new Date(t1 + 'T23:59:59').getTime() + 864e5;
|
||||||
let events = []; try { events = cal.familyEvents({ from, to, fd: day, td: t1 }); } catch {}
|
let events = []; try { events = cal.familyEvents({ from, to, fd: day, td: t1 }); } catch {}
|
||||||
return { day, people: people(), announcement: chat.announcement(), events, chores: chores.board(day).filter(i => !i.user || onBoard(users.get(i.user) || {})).map(i => ({ chore: i.chore, title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done, doneBy: i.done ? i.done.byName : '', claimable: !!i.claimable, claimed: !!i.claimed })), week: (w => ({ from: w.from, tiers: w.tiers, people: w.people.filter(p => onBoard(users.get(p.id) || {})).map(p => ({ id: p.id, name: p.name, points: p.points, count: p.count, tiers: p.tiers, earned: p.earned, given: p.given, goal: p.goal })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
|
return { day, familyName: familyName(), people: people(), announcement: chat.announcement(), events, chores: chores.board(day).filter(i => !i.user || onBoard(users.get(i.user) || {})).map(i => ({ chore: i.chore, title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done, doneBy: i.done ? i.done.byName : '', claimable: !!i.claimable, claimed: !!i.claimed })), week: (w => ({ from: w.from, tiers: w.tiers, people: w.people.filter(p => onBoard(users.get(p.id) || {})).map(p => ({ id: p.id, name: p.name, points: p.points, count: p.count, tiers: p.tiers, earned: p.earned, given: p.given, goal: p.goal })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handle(req, res) {
|
async function handle(req, res) {
|
||||||
@@ -62,6 +69,11 @@ async function handle(req, res) {
|
|||||||
const body = JSON.stringify({ chore: b.chore, date: ymdOk(b.date) ? b.date : undefined, undo: b.action === 'unclaim' || b.action === 'undo' || undefined }), r = Readable.from([Buffer.from(body)]); r.url = path; r.method = 'POST'; r.headers = {};
|
const body = JSON.stringify({ chore: b.chore, date: ymdOk(b.date) ? b.date : undefined, undo: b.action === 'unclaim' || b.action === 'undo' || undefined }), r = Readable.from([Buffer.from(body)]); r.url = path; r.method = 'POST'; r.headers = {};
|
||||||
return users.runAs(t, () => chores.handle(r, res)); // (as that person: all of the chore rules apply to them)
|
return users.runAs(t, () => chores.handle(r, res)); // (as that person: all of the chore rules apply to them)
|
||||||
}
|
}
|
||||||
|
if (p === '/api/board/family' && req.method === 'GET') { if (!me) throw fail(403, 'Sign in first'); return json(res, 200, { name: savedName(), shown: familyName() }); }
|
||||||
|
if (p === '/api/board/family' && req.method === 'POST') { // an admin sets what the family board calls the family
|
||||||
|
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can change this'); const b = JSON.parse(await readBody(req, 1e3) || '{}'), name = String(b.name || '').replace(/[\u0000-\u001f<>]/g, ' ').trim().slice(0, 40);
|
||||||
|
if (name) { fs.mkdirSync(path.dirname(NAME_FILE()), { recursive: true }); atomicWrite(NAME_FILE(), JSON.stringify({ name })); } else fs.rmSync(NAME_FILE(), { force: true }); return json(res, 200, { name, shown: familyName() });
|
||||||
|
}
|
||||||
if (p === '/api/board/goal' && req.method === 'POST') { // a child chooses the reward they are aiming for this week, from the shared screen
|
if (p === '/api/board/goal' && req.method === 'POST') { // a child chooses the reward they are aiming for this week, from the shared screen
|
||||||
if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 2e3) || '{}'), t = users.get(String(b.uid));
|
if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 2e3) || '{}'), t = users.get(String(b.uid));
|
||||||
if (!t || !onBoard(t)) throw fail(404, 'Person not found'); if (!users.can(t, 'chores')) throw fail(403, 'Chores are not turned on for that person'); identify(t, b.pin);
|
if (!t || !onBoard(t)) throw fail(404, 'Person not found'); if (!users.can(t, 'chores')) throw fail(403, 'Chores are not turned on for that person'); identify(t, b.pin);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
body { padding: 18px; }
|
body { padding: 18px; }
|
||||||
.bd { max-width: 1280px; margin: 0 auto; }
|
.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; }
|
.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; }
|
.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; }
|
.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; }
|
.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');
|
const ymd = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||||
let data = null;
|
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>`;
|
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) {
|
function weekCard(w) {
|
||||||
if (!w || !w.people.length) return none('No points yet');
|
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 || ''))));
|
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 || '')))); }
|
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) {
|
function pick(p) {
|
||||||
if (!p) return;
|
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); }
|
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
|
// ---- 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) {
|
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 { 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 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 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 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;
|
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>')
|
+ 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('') : '')
|
+ (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('') : '')
|
+ (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>
|
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 && 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>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 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;
|
const k = box.querySelector('#fs-chores'); if (k) k.onclick = famManageChores;
|
||||||
if (typeof devSettings === 'function') devSettings(box.querySelector('#fs-dev'));
|
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; }
|
if (admin) { famRequests(box.querySelector('#fs-req')); famKids(box.querySelector('#fs-kids')); const w = box.querySelector('#fs-words'); if (w) w.onclick = famWords; }
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const hub = http.createServer((req, res) => req.url.startsWith('/api/chores') ?
|
|||||||
assert.equal((await act(kid.id, 'undo')).status, 200); assert.equal((await post('/api/board/chore', { uid: kid.id, chore: ch.id, action: 'done' }, '')).status, 403, 'only a family device can do this'); assert.equal((await act(kid.id, 'nope')).status, 400);
|
assert.equal((await act(kid.id, 'undo')).status, 200); assert.equal((await post('/api/board/chore', { uid: kid.id, chore: ch.id, action: 'done' }, '')).status, 403, 'only a family device can do this'); assert.equal((await act(kid.id, 'nope')).status, 400);
|
||||||
const rw = (await post('/api/chores/reward', { title: 'Movie night', points: 20 }, 'hub_session=' + session(ann).slice(12))).json; assert.ok(rw && rw.id, 'a reward exists'); const gl = (b, who = dev) => post('/api/board/goal', { uid: kid.id, date: today, ...b }, who);
|
const rw = (await post('/api/chores/reward', { title: 'Movie night', points: 20 }, 'hub_session=' + session(ann).slice(12))).json; assert.ok(rw && rw.id, 'a reward exists'); const gl = (b, who = dev) => post('/api/board/goal', { uid: kid.id, date: today, ...b }, who);
|
||||||
assert.equal((await gl({ reward: rw.id }, '')).status, 403, 'only a family device can choose'); assert.equal((await gl({ reward: rw.id })).status, 200, 'Kai chooses a goal from the family screen'); assert.equal(((await call('/api/board?day=' + today, {}, dev)).json.week.people.find(p => p.id === kid.id) || {}).goal, rw.id, 'and the board shows it as the goal'); assert.equal((await gl({ reward: 'nope' })).status, 400);
|
assert.equal((await gl({ reward: rw.id }, '')).status, 403, 'only a family device can choose'); assert.equal((await gl({ reward: rw.id })).status, 200, 'Kai chooses a goal from the family screen'); assert.equal(((await call('/api/board?day=' + today, {}, dev)).json.week.people.find(p => p.id === kid.id) || {}).goal, rw.id, 'and the board shows it as the goal'); assert.equal((await gl({ reward: 'nope' })).status, 400);
|
||||||
|
const fam = 'hub_session=' + session(ann).slice(12); assert.equal(typeof (await call('/api/board?day=' + today, {}, dev)).json.familyName, 'string'); assert.equal((await post('/api/board/family', { name: 'Emmert Family' }, dev)).status, 403, 'only a signed-in admin sets the family name'); assert.equal((await post('/api/board/family', { name: 'Emmert Family' }, fam)).status, 200); assert.equal((await call('/api/board?day=' + today, {}, dev)).json.familyName, 'Emmert Family', 'the board greets the family by name');
|
||||||
const add = b => post('/api/board/add', { uid: kid.id, ...b }, dev), day0 = new Date().toISOString().slice(0, 10);
|
const add = b => post('/api/board/add', { uid: kid.id, ...b }, dev), day0 = new Date().toISOString().slice(0, 10);
|
||||||
assert.equal((await add({ kind: 'task', text: 'Water the plants', due: day0 })).status, 200, 'Kai adds a family task'); assert.ok((await call('/api/tasks?list=local:family', {}, 'hub_session=' + session(ann).slice(12))).status !== 500); assert.ok(JSON.stringify((await call('/api/board?day=' + day0, {}, dev)).json.tasks).includes('Water the plants'), 'and it shows on the board');
|
assert.equal((await add({ kind: 'task', text: 'Water the plants', due: day0 })).status, 200, 'Kai adds a family task'); assert.ok((await call('/api/tasks?list=local:family', {}, 'hub_session=' + session(ann).slice(12))).status !== 500); assert.ok(JSON.stringify((await call('/api/board?day=' + day0, {}, dev)).json.tasks).includes('Water the plants'), 'and it shows on the board');
|
||||||
assert.equal((await add({ kind: 'event', text: 'Movie night', date: day0, time: '19:00', tz: 'UTC' })).status, 200); assert.ok(JSON.stringify((await call('/api/board?day=' + day0, {}, dev)).json.events).includes('Movie night'), 'a timed event lands on the family calendar');
|
assert.equal((await add({ kind: 'event', text: 'Movie night', date: day0, time: '19:00', tz: 'UTC' })).status, 200); assert.ok(JSON.stringify((await call('/api/board?day=' + day0, {}, dev)).json.events).includes('Movie night'), 'a timed event lands on the family calendar');
|
||||||
|
|||||||
Reference in new issue
Block a user