Children pick a weekly reward goal, with a Monday reminder

A child chooses the reward they are aiming for (an admin can choose for them); the goal belongs to one week and starts empty each Monday. A child with none gets one daytime reminder that week, settled when they pick. Shown on Chores and the family board.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 09:13:32 -04:00
1 parent c97b62004e
commit 714680a94e
7 files changed
+73 -11

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ function clock() { const d = new Date(); $('#clock').textContent = d.toLocaleTim
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)), 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 `<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">${mine.map(t => `<span style="position:absolute;top:0;left:${Math.round(t.points / top * 100)}%;width:2px;height:100%;background:var(--text);opacity:.5"></span>`).join('')}<i style="width:${Math.round(p.points / top * 100)}%"></i></div><small>${got.length ? '🎁 ' + esc(got.map(t => t.title).join(', ')) : ''}${next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}</small></div>`; }).join('');
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), goal = mine.find(t => t.id === p.goal), got = mine.filter(t => p.earned.includes(t.id)); 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">${mine.map(t => `<span style="position:absolute;top:0;left:${Math.round(t.points / top * 100)}%;width:2px;height:100%;background:var(--text);opacity:.5"></span>`).join('')}<i style="width:${Math.round(p.points / top * 100)}%"></i></div><small>${got.length ? '🎁 ' + esc(got.map(t => t.title).join(', ')) : ''}${goal ? (got.length ? ' · ' : '') + (p.points >= goal.points ? '🎯 ' + esc(goal.title) + ' reached!' : '🎯 ' + (goal.points - p.points) + ' more for ' + esc(goal.title)) : next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}</small></div>`; }).join('');
}
const none = t => `<p class="none">${t}</p>`;
+18 -3
View File
@@ -13,12 +13,25 @@ const crMonday = s => crShift(s, -((new Date(s + 'T12:00:00').getDay() + 6) % 7)
const crWeekLabel = w => { const f = new Date(w.from + 'T12:00:00'), t = new Date(w.to + 'T12:00:00'), o = { month: 'short', day: 'numeric' }; return (w.from === crMonday(crYmd(new Date())) ? 'This week · ' : '') + f.toLocaleDateString(undefined, o) + ' to ' + t.toLocaleDateString(undefined, o); };
/** One person's week: points, how far to the next reward, the rewards reached (an admin marks each as handed over), and the chores they did. */
function crWeekPerson(p) {
const w = cr.week, admin = cr.data.me.admin, tiers = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...tiers.map(t => t.points), p.points), next = tiers.find(t => t.points > p.points);
return `<div class="crwk"><div class="crrank" style="border:0;padding-bottom:4px"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b><div class="statbar" style="position:relative"><i style="width:${Math.round(p.points / top * 100)}%"></i>${tiers.map(t => `<span class="crtick" style="left:${Math.round(t.points / top * 100)}%" title="${esc(t.points + ' pts: ' + t.title)}"></span>`).join('')}</div></span><small>${p.points} pts · ${p.count} done</small></div>
${next ? `<small class="sub" style="display:block;margin:0 0 4px 52px">${next.points - p.points} more for ${esc(next.title)}</small>` : tiers.length ? '<small class="sub" style="display:block;margin:0 0 4px 52px">Every reward reached 🎉</small>' : ''}
const w = cr.week, admin = cr.data.me.admin, tiers = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...tiers.map(t => t.points), p.points), next = tiers.find(t => t.points > p.points), goal = tiers.find(t => t.id === p.goal), canPick = tiers.length && (p.id === cr.data.me.id || admin);
return `<div class="crwk"><div class="crrank" style="border:0;padding-bottom:4px"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b><div class="statbar" style="position:relative"><i style="width:${Math.round(p.points / top * 100)}%"></i>${tiers.map(t => `<span class="crtick${t.id === p.goal ? ' goal' : ''}" style="left:${Math.round(t.points / top * 100)}%" title="${esc(t.points + ' pts: ' + t.title)}"></span>`).join('')}</div></span><small>${p.points} pts · ${p.count} done</small></div>
${goal ? `<small class="sub" style="display:block;margin:0 0 4px 52px">🎯 ${p.points >= goal.points ? esc(goal.title) + ': reached! 🎉' : (goal.points - p.points) + ' more for ' + esc(goal.title)}</small>` : next ? `<small class="sub" style="display:block;margin:0 0 4px 52px">${next.points - p.points} more for ${esc(next.title)}</small>` : tiers.length ? '<small class="sub" style="display:block;margin:0 0 4px 52px">Every reward reached 🎉</small>' : ''}
${canPick ? `<button type="button" class="btn ghost crgoalbtn" data-goal="${esc(p.id)}">${goal ? 'Change goal' : 'Pick a goal'}</button>` : ''}
${p.earned.map(id => { const t = w.tiers.find(x => x.id === id), given = p.given.includes(id); return `<div class="crearn"><span>🎁</span><span class="grow"><b>${esc(t.title)}</b><small>${given ? 'Given' : 'Earned, not given yet'}</small></span>${admin ? `<button class="btn ${given ? 'ghost' : ''}" data-give="${esc(p.id)}|${esc(id)}" ${given ? 'data-undo="1"' : ''}>${given ? 'Undo' : 'Mark given'}</button>` : ''}</div>`; }).join('')}
${p.done.length ? `<details class="crdet"><summary>Chores done (${p.done.length})</summary>${p.done.map(c => `<div class="crrank" style="border:0;padding:3px 0"><span class="grow">${esc(c.title)}<small style="display:block">${esc(new Date(c.date + 'T12:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }))}</small></span><small>${c.pts ? c.pts + ' pts' : ''}</small></div>`).join('')}</details>` : ''}</div>`;
}
/** A child's own "aiming for" card at the top of Chores: the reward they chose and how far they have to go this week. */
function crGoalCard() {
const me = cr.data.me.id, p = cr.week.people.find(x => x.id === me); if (!p || !p.tiers.length) return '';
const t = cr.week.tiers.find(x => x.id === p.goal), now = cr.week.from === crMonday(crYmd(new Date()));
return `<div class="crgoal"><span class="crgi">🎯</span><span class="grow"><small>Aiming for</small><b>${t ? esc(t.title) : 'Pick a reward to aim for'}</b>${t && now ? `<small>${p.points >= t.points ? 'You did it! 🎉' : (t.points - p.points) + ' more points this week'}</small>` : ''}</span><button type="button" class="btn ${t ? 'ghost' : ''}" id="crgoal">${t ? 'Change' : 'Pick'}</button></div>`;
}
function crPickGoal(uid) {
const p = cr.week.people.find(x => x.id === uid); if (!p) return; const tiers = cr.week.tiers.filter(t => p.tiers.includes(t.id));
const el = openSheet('What are you aiming for?', uid === cr.data.me.id ? 'Pick a reward for this week. Chores shows how far you have to go. You pick again each Monday.' : 'Choose a goal for ' + p.name, `<div class="list">${tiers.map(t => `<button class="row" data-g="${esc(t.id)}"><span class="ic" style="background:var(--accent)">🎁</span><span class="grow"><b>${esc(t.title)}</b><small>${t.points} points in a week${t.minutes ? ' · +' + t.minutes + ' min screen time' : ''}</small></span>${p.goal === t.id ? icon('check') : ''}</button>`).join('')}</div>${p.goal ? '<div class="bar"><button class="btn ghost" id="crgno" style="width:100%;justify-content:center">No goal for now</button></div>' : ''}`);
const set = async r => { try { await crPost('/goal', { reward: r, user: uid, date: crYmd(new Date()) }); if (typeof inboxSettled === 'function') inboxSettled([{ tag: 'goal:' + crMonday(crYmd(new Date())) }]); closeSheet(); viewChores(); } catch (e) { toast(e.message); } };
el.querySelectorAll('[data-g]').forEach(b => b.onclick = () => set(b.dataset.g)); const n = el.querySelector('#crgno'); if (n) n.onclick = () => set('');
}
/** After a change in the manage sheets: redraw the Chores page, or (when opened from Settings) reopen the manager. */
const crRefresh = () => location.hash.startsWith('#/chores') ? viewChores() : (cr.afterEdit && cr.afterEdit());
async function viewChores() {
@@ -31,6 +44,7 @@ async function viewChores() {
const person = p => { const l = d.board.filter(i => i.user === p.id), done = l.filter(i => i.done).length; return `<div class="crperson"><div class="crph"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><b>${esc(p.name)}</b><small>${l.length ? done + ' of ' + l.length : 'nothing today'}</small></div>${l.length ? `<div class="crbar"><i style="width:${Math.round(done / l.length * 100)}%"></i></div>` : ''}<div>${l.map(item).join('')}</div></div>`; };
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Chores</h1><span class="pspace grow"></span>${d.me.admin ? `<button class="btn ghost" id="crman">${icon('cog')}Manage</button>` : ''}</div>
<div class="bar" style="justify-content:flex-start;margin:0 0 8px"><button class="ibtn" id="crprev" aria-label="Previous day">${icon('back')}</button><b style="min-width:150px;text-align:center">${esc(crDayLabel(cr.day))}</b><button class="ibtn" id="crnext" aria-label="Next day" style="transform:scaleX(-1)">${icon('back')}</button>${cr.day !== crYmd(new Date()) ? '<button class="btn ghost" id="crtoday">Today</button>' : ''}</div>
${crGoalCard()}
<h2>Mine</h2><div class="crcard">${mine.map(item).join('') || '<p class="empty" style="margin:8px 0">Nothing for you ' + (cr.day === crYmd(new Date()) ? 'today' : 'on this day') + '. 🎉</p>'}</div>
${anyone.length ? `<h2>Anyone can do</h2><div class="crcard">${anyone.map(item).join('')}</div>` : ''}
${others.length ? `<h2>The family</h2><div class="crgrid">${others.map(person).join('')}</div>` : ''}
@@ -43,6 +57,7 @@ async function viewChores() {
$('#crprev').onclick = () => { cr.day = crShift(cr.day, -1); viewChores(); }; $('#crnext').onclick = () => { cr.day = crShift(cr.day, 1); viewChores(); }; if ($('#crtoday')) $('#crtoday').onclick = () => { cr.day = crYmd(new Date()); viewChores(); };
$('#wkprev').onclick = () => { cr.wk = crShift(cr.week.from, -7); viewChores(); }; $('#wknext').onclick = () => { cr.wk = crShift(cr.week.from, 7); viewChores(); }; if ($('#wktoday')) $('#wktoday').onclick = () => { cr.wk = crYmd(new Date()); viewChores(); };
v.querySelectorAll('[data-give]').forEach(b => b.onclick = async () => { const [u, r] = b.dataset.give.split('|'); try { await crPost('/give', { week: cr.week.from, user: u, reward: r, undo: !!b.dataset.undo }); viewChores(); } catch (e) { toast(e.message); } });
if ($('#crgoal')) $('#crgoal').onclick = () => crPickGoal(cr.data.me.id); v.querySelectorAll('.crgoalbtn').forEach(b => b.onclick = () => crPickGoal(b.dataset.goal));
if ($('#crman')) $('#crman').onclick = crManage;
}
+3
View File
@@ -1072,6 +1072,9 @@ body.vizopen { overflow: hidden; }
.crtick { position: absolute; top: 0; width: 2px; height: 100%; background: var(--text); opacity: .55; } .crearn { display: flex; align-items: center; gap: 10px; margin: 6px 0 0 52px; padding: 8px 10px; border-radius: 12px; background: var(--panel2); } .crearn .btn { min-height: 34px; padding: 0 12px; }
.crdet { margin: 6px 0 0 52px; } .crdet summary { cursor: pointer; color: var(--muted); font-size: 14px; } .crwk { padding: 8px 0; border-bottom: 1px solid var(--line); } .crwk:last-child { border-bottom: 0; }
.crtick.goal { width: 4px; background: var(--accent); opacity: 1; }
.crgoal { display: flex; align-items: center; gap: 12px; margin: 8px 0 4px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); } .crgoal .crgi { font-size: 26px; } .crgoal .grow { min-width: 0; } .crgoal b, .crgoal small { display: block; } .crgoal small { color: var(--muted); }
.crgoalbtn { margin: 4px 0 0 52px; min-height: 36px; font-size: 14px; }
#kidreq { position: fixed; left: 12px; bottom: calc(env(safe-area-inset-bottom, 0px) + 74px + var(--playerh, 0px)); z-index: 55; min-height: 40px; padding: 0 16px; border-radius: 22px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; font-weight: 600; box-shadow: 0 4px 14px rgba(0,0,0,.3); cursor: pointer; }
@media (min-width: 900px) { #kidreq { bottom: calc(16px + var(--playerh, 0px)); left: 236px; } }