Family board: rewards show only the chosen goal, with a Choose a reward pop-up for children without one
build-container / build (push) Successful in 3m44s
build-container / build (push) Successful in 3m44s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a33119807
commit
48c65779d3
4 files changed
+24
-3
No files matched your search
@@ -62,6 +62,12 @@ 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 = {};
|
||||
return users.runAs(t, () => chores.handle(r, res)); // (as that person: all of the chore rules apply to them)
|
||||
}
|
||||
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 (!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);
|
||||
const r = Readable.from([Buffer.from(JSON.stringify({ reward: String(b.reward || ''), date: ymdOk(b.date) ? b.date : undefined }))]); r.url = '/api/chores/goal'; r.method = 'POST'; r.headers = {};
|
||||
return users.runAs(t, () => chores.handle(r, res)); // (as that person: the same rules as choosing it in Chores)
|
||||
}
|
||||
if (p === '/api/board/add' && req.method === 'POST') { // put something on one of the family's lists from the shared screen, as the person who picked their name
|
||||
if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 8e3) || '{}'), t = users.get(String(b.uid));
|
||||
if (!t || !onBoard(t)) throw fail(404, 'Person not found'); const text = String(b.text || '').replace(/\s+/g, ' ').trim().slice(0, 200); if (!text) throw fail(400, 'Write something first');
|
||||
|
||||
+15
-2
@@ -9,7 +9,20 @@ 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), 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('');
|
||||
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) {
|
||||
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'); close(); await load(); 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>`;
|
||||
|
||||
@@ -33,7 +46,7 @@ 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-add]').forEach(b => b.onclick = () => addFlow(b.dataset.add)); const all = document.getElementById('poolall'); if (all) all.onclick = poolAll; document.querySelectorAll(".pill[data-c]").forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c && (c.user || '') === (b.dataset.u || '')))); }
|
||||
function wireChores() { 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) {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v143';
|
||||
const CACHE = 'hub-v144';
|
||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||
|
||||
@@ -24,6 +24,8 @@ const hub = http.createServer((req, res) => req.url.startsWith('/api/chores') ?
|
||||
assert.equal((await act(kid.id, 'claim')).status, 200, 'Kai takes it with one tap'); cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.claimed, true); assert.equal(cb.userName, 'Kai');
|
||||
assert.equal((await act(bob.id, 'done')).status, 403, 'Bob has no PIN, so he cannot act as himself yet'); assert.equal((await act(kid.id, 'done')).status, 200); cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.done, true); assert.equal(cb.doneBy, 'Kai');
|
||||
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);
|
||||
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 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: '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