Chores: weekly goals with custom reward tiers (everyone or chosen people)
build-container / build (push) Successful in 3m53s
build-container / build (push) Successful in 3m53s
- Admin-set reward tiers; points count Monday to Sunday and restart each week - Per-person weekly totals, chores done, progress bar toward the next reward, week history - Mark given / undo when a reward is handed over - Rewards can be for everyone or only chosen people - "This week" card on the family board - Tests and README updated Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0df1ff5705
commit
17a282054a
8 files changed
+73
-13
No files matched your search
@@ -184,7 +184,7 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions
|
||||
|
||||
**Notes** (turn it on per person): notes and checklists that are private, shared with the whole family, or shared with chosen people. Anyone it is shared with can edit it. Checklist items change one at a time (tick, add, edit, remove), so two people ticking the same list never overwrite each other, ticked items say who ticked them, and an open list follows other people's changes every few seconds. Text edits are checked against a revision, so a stale edit is refused instead of overwriting someone else's. Pin, colour, archive, search and filters (lists, notes, mine, shared, archived), ready-made lists (grocery, packing, weekend to-do), *Share to Chat*, and *Send the open items to Tasks*. Only the person who made a note (or an admin) changes who it is shared with or deletes it.
|
||||
|
||||
**Chores** (turn it on per person): jobs that repeat daily, on weekdays, on chosen days of the week, weekly, monthly or happen once, given to one or more people or to anyone. Each person ticks off their own (an admin can tick for someone), finished chores earn points, and the board adds up the week and month. Only an admin adds or changes chores. A chore given to *anyone* is a pool: a person taps **Take it** to claim it for the day (it moves under their name, and nobody else can tick it; **Put back** returns it to the pool). Kept in `data/chores.json`.
|
||||
**Chores** (turn it on per person): jobs that repeat daily, on weekdays, on chosen days of the week, weekly, monthly or happen once, given to one or more people or to anyone. Each person ticks off their own (an admin can tick for someone), finished chores earn points, and the board adds up the week and month. **Weekly goals:** an admin makes reward tiers in Manage (for example 20 points: pick the movie, 40 points: ice cream). Points count Monday to Sunday and start again every Monday, with each person's total, the chores they did that week and a bar toward the next reward (also on the family board). Step back through earlier weeks with the arrows; nothing is deleted. When someone reaches a tier, an admin taps **Mark given** once the reward is handed over. Only an admin adds or changes chores. A chore given to *anyone* is a pool: a person taps **Take it** to claim it for the day (it moves under their name, and nobody else can tick it; **Put back** returns it to the pool). Kept in `data/chores.json`.
|
||||
|
||||
**Family** (the page every account has next to Home): today and tomorrow's calendar, your chores and everyone's progress, the shopping list, shared notes and lists, recent chats and the tasks due this week. Each card only shows if the person has that part of the hub.
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ function identify(t, pin, req) {
|
||||
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;
|
||||
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 })), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
|
||||
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 })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
|
||||
@@ -18,7 +18,7 @@ const addDays = (s, n) => { const d = utc(s); d.setUTCDate(d.getUTCDate() + n);
|
||||
const weekday = s => utc(s).getUTCDay(); // 0 = Sunday
|
||||
const monday = s => addDays(s, -((weekday(s) + 6) % 7));
|
||||
let cache = null;
|
||||
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return { chores: Array.isArray(j.chores) ? j.chores : [], log: Array.isArray(j.log) ? j.log : [], claims: Array.isArray(j.claims) ? j.claims : [] }; } catch { return { chores: [], log: [], claims: [] }; } })());
|
||||
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return { chores: Array.isArray(j.chores) ? j.chores : [], log: Array.isArray(j.log) ? j.log : [], claims: Array.isArray(j.claims) ? j.claims : [], rewards: Array.isArray(j.rewards) ? j.rewards : [], given: Array.isArray(j.given) ? j.given : [] }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [] }; } })());
|
||||
const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); };
|
||||
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chores'); };
|
||||
const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
|
||||
@@ -69,12 +69,22 @@ function summary(range, end) {
|
||||
return { range, from, to: end, people: [...per.values()].sort((a, b) => b.points - a.points || b.count - a.count) };
|
||||
}
|
||||
|
||||
/** One calendar week (Monday to Sunday): each person's points, the chores they did, which reward tiers they reached, and which rewards were handed over. Points start again each Monday; the history stays. */
|
||||
const forMe = (t, uid) => !Array.isArray(t.for) || t.for.includes(uid); // a reward is for everyone, or for chosen people
|
||||
function week(day) {
|
||||
const from = monday(day), to = addDays(from, 6), d = db(), tiers = d.rewards.slice().sort((a, b) => a.points - b.points), per = new Map(people().map(p => [p.id, { id: p.id, name: p.name, points: 0, count: 0, done: [] }]));
|
||||
for (const l of d.log) { if (l.date < from || l.date > to) continue; const e = per.get(l.by) || (live(l.by) ? per.set(l.by, { id: l.by, name: nameOf(l.by), points: 0, count: 0, done: [] }).get(l.by) : null); if (!e) continue; const c = d.chores.find(x => x.id === l.chore); e.points += l.pts || 0; e.count++; e.done.push({ title: c ? c.title : 'A chore that was removed', date: l.date, pts: l.pts || 0 }); }
|
||||
const out = [...per.values()].map(e => ({ ...e, done: e.done.sort((a, b) => a.date.localeCompare(b.date)), tiers: tiers.filter(t => forMe(t, e.id)).map(t => t.id), earned: tiers.filter(t => forMe(t, e.id) && e.points >= t.points).map(t => t.id), given: d.given.filter(g => g.week === from && g.user === e.id).map(g => g.reward) })).sort((a, b) => b.points - a.points || b.count - a.count || a.name.localeCompare(b.name));
|
||||
return { from, to, tiers, people: out };
|
||||
}
|
||||
const cleanReward = (b, old) => { const title = str(b.title, 80); if (!title) throw fail(400, 'Say what the reward is'); const points = Math.round(+b.points); if (!(points >= 1 && points <= 1000)) throw fail(400, 'Choose the points needed (1 to 1000)'); const r = { id: old ? old.id : newId(), points, title }; const who = Array.isArray(b.for) ? [...new Set(b.for.map(String))].filter(live).slice(0, 30) : null; if (who) { if (!who.length) throw fail(400, 'Choose who it is for'); r.for = who; } return r; };
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/chores'.length) || '/', me = users.current(), q = u.searchParams, admin = me.role === 'admin';
|
||||
try {
|
||||
let m;
|
||||
const day = q.get('date') || ymd(new Date()); if (!ymdOk(day)) throw fail(400, 'Bad date');
|
||||
if (p === '/' && req.method === 'GET') return json(res, 200, { me: { id: me.id, admin }, day, people: people(), chores: db().chores, board: board(day).map(i => ({ ...i, mine: i.user === me.id || i.user === null, canDo: admin || i.user === me.id || i.user === null })) });
|
||||
if (p === '/' && req.method === 'GET') return json(res, 200, { me: { id: me.id, admin }, day, rewards: db().rewards.slice().sort((a, b) => a.points - b.points), people: people(), chores: db().chores, board: board(day).map(i => ({ ...i, mine: i.user === me.id || i.user === null, canDo: admin || i.user === me.id || i.user === null })) });
|
||||
if (p === '/' && req.method === 'POST') {
|
||||
if (!admin) throw fail(403, 'Only an admin can add or change chores'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), old = b.id ? db().chores.find(x => x.id === b.id) : null; if (b.id && !old) throw fail(404, 'Chore not found'); if (!old && db().chores.length >= MAX_CHORES) throw fail(400, 'That is plenty of chores');
|
||||
const c = clean(b, old); if (old) db().chores[db().chores.indexOf(old)] = c; else db().chores.push(c); save(); return json(res, 200, c);
|
||||
@@ -101,6 +111,17 @@ async function handle(req, res) {
|
||||
if (i >= 0 && live(d.claims[i].user)) { if (d.claims[i].user === me.id) return json(res, 200, { ok: true, already: true }); throw fail(409, nameOf(d.claims[i].user) + ' already took this one'); }
|
||||
if (i >= 0) d.claims.splice(i, 1); d.claims.push({ chore: c.id, key, user: me.id, at: Date.now() }); if (d.claims.length > 2000) d.claims.splice(0, d.claims.length - 2000); save(); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/week' && req.method === 'GET') return json(res, 200, week(day));
|
||||
if (p === '/reward' && req.method === 'POST') { // make, change or remove a reward tier (admin)
|
||||
if (!admin) throw fail(403, 'Only an admin can change the rewards'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), d = db(), old = b.id ? d.rewards.find(x => x.id === b.id) : null;
|
||||
if (b.id && !old) throw fail(404, 'Reward not found'); if (b.remove) { d.rewards = d.rewards.filter(x => x !== old); d.given = d.given.filter(g => g.reward !== b.id); save(); return json(res, 200, { ok: true }); }
|
||||
if (!old && d.rewards.length >= 12) throw fail(400, 'That is plenty of rewards'); const r = cleanReward(b, old); if (old) d.rewards[d.rewards.indexOf(old)] = r; else d.rewards.push(r); save(); return json(res, 200, r);
|
||||
}
|
||||
if (p === '/give' && req.method === 'POST') { // note that a reward was handed over (admin), or take it back
|
||||
if (!admin) throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), d = db(), wk = monday(ymdOk(b.week) ? b.week : day), i = d.given.findIndex(g => g.week === wk && g.user === String(b.user) && g.reward === String(b.reward));
|
||||
if (b.undo) { if (i >= 0) d.given.splice(i, 1); save(); return json(res, 200, { ok: true }); }
|
||||
const w = week(wk), e = w.people.find(x => x.id === String(b.user)); if (!e || !e.earned.includes(String(b.reward))) throw fail(400, 'They have not earned that one'); if (i < 0) d.given.push({ week: wk, user: e.id, reward: String(b.reward), at: Date.now(), by: me.id }); if (d.given.length > 3000) d.given.splice(0, d.given.length - 3000); save(); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/summary' && req.method === 'GET') return json(res, 200, summary(q.get('range') === 'month' ? 'month' : 'week', day));
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
|
||||
@@ -108,4 +129,4 @@ async function handle(req, res) {
|
||||
|
||||
/** Chores whose name or notes mention the words. */
|
||||
const find = q => db().chores.filter(c => (c.title + ' ' + c.notes).toLowerCase().includes(q)).map(c => ({ id: c.id, title: c.title, repeat: c.repeat, who: c.assign === 'anyone' ? 'Anyone' : assignees(c).map(nameOf).join(', '), points: c.points }));
|
||||
module.exports = { handle, dueKey, board, summary, ymd, addDays, find };
|
||||
module.exports = { handle, dueKey, board, summary, week, ymd, addDays, find };
|
||||
+5
-1
@@ -7,6 +7,10 @@ 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) => `<section class="card"><h2>${t}</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('');
|
||||
}
|
||||
const none = t => `<p class="none">${t}</p>`;
|
||||
|
||||
function draw() {
|
||||
@@ -20,7 +24,7 @@ function draw() {
|
||||
const chores = (pool.length ? `<small class="sub">Up for grabs: tap to take one</small>${pool.map(row).join('')}` : '') + Object.keys(per).map(k => { const l = per[k], dn = l.filter(c => c.done).length; return `<div style="margin-top:8px"><div style="display:flex;justify-content:space-between"><b>${esc(k)}</b><small class="sub">${dn} of ${l.length}</small></div><div class="bar2"><i style="width:${Math.round(dn / l.length * 100)}%"></i></div>${l.map(row).join('')}</div>`; }).join('');
|
||||
$('#grid').innerHTML = card('Today and tomorrow', day('Today', t0) + day('Tomorrow', t1))
|
||||
+ card('Chores today', chores || none('No chores today'))
|
||||
+ card('Shopping list', d.shopping.map(i => `<div class="ln"><span class="g">${esc(i.text)}</span></div>`).join('') || none('Nothing to buy'))
|
||||
+ card('This week', weekCard(d.week)) + card('Shopping list', d.shopping.map(i => `<div class="ln"><span class="g">${esc(i.text)}</span></div>`).join('') || none('Nothing to buy'))
|
||||
+ card('Family tasks', d.tasks.map(t => `<div class="ln"><span class="g">${esc(t.title)}</span><small style="${t.due && t.due < t0 ? 'color:var(--danger)' : ''}">${t.due === t0 ? 'Today' : esc(t.due)}</small></div>`).join('') || none('Nothing due'))
|
||||
+ card('Family notes and lists', d.notes.map(n => `<div class="ln"><span class="g"><b>${esc(n.title)}</b>${n.type === 'list' ? ` <small>${n.done} of ${n.total} done${n.next ? ' · next: ' + esc(n.next) : ''}</small>` : ` <small>${esc(n.body)}</small>`}</span></div>`).join('') || none('No shared notes'));
|
||||
wireChores();
|
||||
|
||||
+30
-6
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
// ---- Chores: today's chores for you and the family, ticking them off, points for the week, and (admin) making and changing chores. See chores.js.
|
||||
const cr = { day: '', data: null, range: 'week', sum: null };
|
||||
const cr = { day: '', data: null, wk: '', week: null };
|
||||
const crApi = (p, o) => jfetch('/api/chores' + p, o);
|
||||
const crPost = (p, b) => crApi(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
|
||||
const crYmd = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
@@ -9,10 +9,20 @@ const crDayLabel = s => s === crYmd(new Date()) ? 'Today' : s === crShift(crYmd(
|
||||
const CR_REPEAT = { daily: 'Every day', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', once: 'One time' };
|
||||
const CR_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
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>' : ''}
|
||||
${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>`;
|
||||
}
|
||||
async function viewChores() {
|
||||
const v = $('#view'); cr.day = cr.day || crYmd(new Date());
|
||||
v.innerHTML = '<div class="wrap"><h1>Chores</h1><p class="empty">Loading…</p></div>';
|
||||
try { [cr.data, cr.sum] = await Promise.all([crApi('/?date=' + cr.day), crApi('/summary?range=' + cr.range + '&date=' + cr.day)]); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Chores</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
try { cr.wk = cr.wk || crYmd(new Date()); [cr.data, cr.week] = await Promise.all([crApi('/?date=' + cr.day), crApi('/week?date=' + cr.wk)]); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Chores</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
if (!location.hash.startsWith('#/chores')) return;
|
||||
const d = cr.data, me = d.me.id, mine = d.board.filter(i => i.user === me), anyone = d.board.filter(i => i.user === null), others = d.people.filter(p => p.id !== me);
|
||||
const item = i => `<label class="critem ${i.done ? 'done' : ''}"><input type="checkbox" data-c="${esc(i.chore)}" data-u="${esc(i.user || '')}" ${i.done ? 'checked' : ''} ${i.canDo ? '' : 'disabled'}><span class="grow"><b>${esc(i.title)}</b>${i.notes ? `<small>${esc(i.notes)}</small>` : ''}${i.claimed && !i.done && i.user !== me ? `<small>${esc(i.userName)} took this</small>` : ''}${i.done ? `<small>Done by ${esc(i.done.byName)}</small>` : i.overdue ? '<small style="color:var(--danger)">Overdue</small>' : ''}</span>${i.points ? `<em class="crpts">${i.points} pts</em>` : ''}${i.claimable && !i.done ? `<button type="button" class="btn ghost crclaim" data-c="${esc(i.chore)}">Take it</button>` : ''}${i.claimed && !i.done && (i.user === me || d.me.admin) ? `<button type="button" class="btn ghost crclaim" data-c="${esc(i.chore)}" data-undo="1">Put back</button>` : ''}</label>`;
|
||||
@@ -23,12 +33,15 @@ async function viewChores() {
|
||||
<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>` : ''}
|
||||
<div class="ptop"><h2 style="margin:18px 0 6px">Points</h2><span class="pspace grow"></span><div class="seg" style="margin:0"><a href="#" data-r="week" class="${cr.range === 'week' ? 'on' : ''}">Week</a><a href="#" data-r="month" class="${cr.range === 'month' ? 'on' : ''}">Month</a></div></div>
|
||||
<div class="crcard">${cr.sum.people.map((p, i) => `<div class="crrank"><span class="num">${i + 1}</span><span class="grow"><b>${esc(p.name)}</b><div class="statbar"><i style="width:${Math.round(p.points / max * 100)}%"></i></div></span><small>${p.points} pts · ${p.count} done</small></div>`).join('') || '<p class="empty">No chores yet.</p>'}</div></div>`;
|
||||
<div class="ptop"><h2 style="margin:18px 0 6px">Weekly goals</h2><span class="pspace grow"></span></div>
|
||||
<div class="bar" style="justify-content:flex-start;margin:0 0 8px"><button class="ibtn" id="wkprev" aria-label="Previous week">${icon('back')}</button><b style="min-width:170px;text-align:center">${esc(crWeekLabel(cr.week))}</b><button class="ibtn" id="wknext" aria-label="Next week" style="transform:scaleX(-1)">${icon('back')}</button>${cr.week.from !== crMonday(crYmd(new Date())) ? '<button class="btn ghost" id="wktoday">This week</button>' : ''}</div>
|
||||
${cr.week.tiers.length ? `<p class="sub" style="margin:0 0 8px">${cr.week.tiers.map(t => esc(t.points) + ' pts: ' + esc(t.title) + (Array.isArray(t.for) ? ' (' + esc(t.for.map(id => (cr.data.people.find(p => p.id === id) || { name: '?' }).name).join(', ')) + ')' : '')).join(' · ')}. Points start again every Monday.</p>` : (d.me.admin ? '<p class="sub">Add rewards in Manage (for example 20 points: pick the movie) and everyone works toward them each week.</p>' : '')}
|
||||
<div class="crcard">${cr.week.people.map(crWeekPerson).join('') || '<p class="empty">No chores yet.</p>'}</div></div>`;
|
||||
v.querySelectorAll('.crclaim').forEach(b => b.onclick = async e => { e.preventDefault(); try { await crPost('/claim', { chore: b.dataset.c, date: cr.day, undo: !!b.dataset.undo }); if (!b.dataset.undo) toast('It is yours'); viewChores(); } catch (x) { toast(x.message); viewChores(); } });
|
||||
v.querySelectorAll('.critem input').forEach(c => c.onchange = async () => { try { await crPost('/done', { chore: c.dataset.c, date: cr.day, user: c.dataset.u || undefined, undo: !c.checked }); if (c.checked) toast('Nice work!'); viewChores(); } catch (e) { toast(e.message); c.checked = !c.checked; } });
|
||||
$('#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(); };
|
||||
v.querySelectorAll('[data-r]').forEach(a => a.onclick = e => { e.preventDefault(); cr.range = a.dataset.r; 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 ($('#crman')) $('#crman').onclick = crManage;
|
||||
}
|
||||
|
||||
@@ -37,10 +50,21 @@ function crManage() {
|
||||
const d = cr.data, who = c => c.assign === 'anyone' ? 'Anyone' : d.people.filter(p => c.assign.includes(p.id)).map(p => p.name).join(', ');
|
||||
const when = c => CR_REPEAT[c.repeat] + (c.repeat === 'weekly' && c.days && c.days.length ? ' · ' + c.days.map(x => CR_DAYS[x]).join(' ') : '') + (c.repeat === 'monthly' ? ' · day ' + (c.dom || 1) : '') + (c.repeat === 'once' ? ' · ' + c.date : '');
|
||||
const el = openSheet('Manage chores', 'Make jobs for the family and choose who does them', `<div class="list" style="max-height:52dvh;overflow:auto">${d.chores.map(c => `<button class="row" data-e="${esc(c.id)}"><span class="grow"><b>${esc(c.title)}${c.active ? '' : ' (paused)'}</b><small style="white-space:normal">${esc(when(c))} · ${esc(who(c))}${c.points ? ' · ' + c.points + ' pts' : ''}</small></span>${icon('chev', 'chev')}</button>`).join('') || '<p class="empty">No chores yet.</p>'}</div>
|
||||
<div class="bar"><button class="btn" id="crnew" style="width:100%;justify-content:center">${icon('plus')}New chore</button></div>`);
|
||||
<div class="bar"><button class="btn" id="crnew" style="width:100%;justify-content:center">${icon('plus')}New chore</button></div>
|
||||
<h2 style="margin:14px 0 6px">Weekly rewards</h2><div class="list">${d.rewards.map(r => `<button class="row" data-rw="${esc(r.id)}"><span class="grow"><b>${esc(r.title)}</b><small>${r.points} points in a week · ${Array.isArray(r.for) ? esc(r.for.map(id => (d.people.find(p => p.id === id) || { name: '?' }).name).join(', ')) : 'everyone'}</small></span>${icon('chev', 'chev')}</button>`).join('') || '<p class="empty" style="margin:6px 0">No rewards yet.</p>'}</div>
|
||||
<div class="bar"><button class="btn ghost" id="rwnew" style="width:100%;justify-content:center">${icon('plus')}New reward</button></div>`);
|
||||
el.querySelector('#rwnew').onclick = () => { closeSheet(); crReward(null); }; el.querySelectorAll('[data-rw]').forEach(b => b.onclick = () => { closeSheet(); crReward(d.rewards.find(r => r.id === b.dataset.rw)); });
|
||||
el.querySelector('#crnew').onclick = () => { closeSheet(); crEdit(null); };
|
||||
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { closeSheet(); crEdit(d.chores.find(c => c.id === b.dataset.e)); });
|
||||
}
|
||||
function crReward(r) {
|
||||
const el = openSheet(r ? 'Edit reward' : 'New reward', 'Anyone who reaches the points in a week earns it. Points start again each Monday.', `<form id="rwf"><label for="rwt">The reward</label><input type="text" id="rwt" value="${esc(r ? r.title : '')}" maxlength="80" required autocomplete="off" placeholder="Pick the movie, ice cream, $5..."><label for="rwp">Points needed in a week</label><input type="number" id="rwp" min="1" max="1000" value="${r ? r.points : 20}" required>
|
||||
<label>Who is it for?</label><div class="list"><label class="row" style="cursor:pointer"><input type="checkbox" id="rwall"${!r || !Array.isArray(r.for) ? ' checked' : ''}><span class="grow"><b>Everyone</b></span></label></div><div class="list" id="rwpp">${cr.data.people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}"${r && Array.isArray(r.for) && r.for.includes(p.id) ? ' checked' : ''}><span class="grow"><b>${esc(p.name)}</b></span></label>`).join('')}</div><div class="err" id="rwe"></div>
|
||||
<div class="bar">${r ? '<button type="button" class="btn danger" id="rwdel">Delete</button>' : ''}<button type="button" class="btn ghost" id="rwx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
||||
const rsync = () => { el.querySelector('#rwpp').hidden = el.querySelector('#rwall').checked; }; el.querySelector('#rwall').onchange = rsync; rsync();
|
||||
el.querySelector('#rwx').onclick = closeSheet; if (r) el.querySelector('#rwdel').onclick = async () => { try { await crPost('/reward', { id: r.id, remove: true }); closeSheet(); viewChores(); } catch (e) { toast(e.message); } };
|
||||
el.querySelector('#rwf').onsubmit = async e => { e.preventDefault(); try { await crPost('/reward', { id: r && r.id, title: el.querySelector('#rwt').value, points: +el.querySelector('#rwp').value, for: el.querySelector('#rwall').checked ? 'all' : [...el.querySelectorAll('#rwpp [data-p]:checked')].map(i => i.dataset.p) }); closeSheet(); viewChores(); } catch (x) { el.querySelector('#rwe').textContent = x.message; } };
|
||||
}
|
||||
function crEdit(c) {
|
||||
const d = cr.data, x = c || { title: '', notes: '', repeat: 'daily', days: [], dom: 1, date: crYmd(new Date()), assign: 'anyone', points: 0, active: true }, chosen = new Set(x.assign === 'anyone' ? [] : x.assign);
|
||||
const el = openSheet(c ? 'Edit chore' : 'New chore', '', `<form id="cref"><label for="crt">What needs doing</label><input type="text" id="crt" value="${esc(x.title)}" maxlength="120" required autocomplete="off" placeholder="Empty the dishwasher">
|
||||
|
||||
@@ -1060,3 +1060,6 @@ body.vizopen { overflow: hidden; }
|
||||
|
||||
#devchip { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 8px); right: 10px; z-index: 60; display: flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 22px; padding: 4px 4px 4px 14px; box-shadow: 0 4px 14px rgba(0,0,0,.3); }
|
||||
#devchip .btn { min-height: 34px; padding: 0 14px; border-radius: 18px; } .devwho { display: flex; align-items: center; gap: 6px; } .devkid { font-style: normal; background: #2f9e6e; color: #fff; border-radius: 8px; padding: 1px 7px; font-size: 11px; font-weight: 700; }
|
||||
|
||||
.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; }
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v75';
|
||||
const CACHE = 'hub-v77';
|
||||
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'];
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
|
||||
assert.equal((await post('/claim', { chore: sweep.id, date: today }, 'c')).status, 409, 'someone else cannot take it too'); assert.equal((await post('/done', { chore: sweep.id, date: today }, 'c')).status, 403, 'or tick it off');
|
||||
assert.equal((await post('/claim', { chore: sweep.id, date: today, undo: true }, 'c')).status, 403, 'or put it back'); assert.equal((await post('/claim', { chore: sweep.id, date: today, undo: true }, 'b')).status, 200); assert.equal((await board('b')).find(x => x.title === 'Sweep').claimable, true, 'put back in the pool');
|
||||
await post('/claim', { chore: sweep.id, date: today }, 'c'); assert.equal((await post('/done', { chore: sweep.id, date: today }, 'c')).status, 200); sw = (await board('b')).find(x => x.title === 'Sweep'); assert.ok(sw.done && sw.user === P.c.id, 'done by the one who took it'); assert.equal((await post('/claim', { chore: sweep.id, date: today, undo: true }, 'c')).status, 200); assert.equal((await post('/claim', { chore: dishes.id, date: today }, 'b')).status, 400, 'only pool chores');
|
||||
// weekly goals: tiers, a Monday-to-Sunday week, what each person did, handing a reward over
|
||||
assert.equal((await post('/reward', { title: 'Pick the movie', points: 5 }, 'b')).status, 403, 'only an admin sets rewards'); assert.equal((await post('/reward', { title: '', points: 5 })).status, 400); assert.equal((await post('/reward', { title: 'x', points: 0 })).status, 400);
|
||||
const r5 = await j(await post('/reward', { title: 'Pick the movie', points: 5 })), r50 = await j(await post('/reward', { title: 'A trip', points: 50 })); let wk = await j(await call('/week?date=' + today, {}, 'a')); assert.equal(wk.from, chores.addDays(wk.from, 0)); assert.equal(new Date(wk.from + 'T12:00:00Z').getUTCDay(), 1, 'a week starts on Monday'); assert.equal(wk.tiers.length, 2);
|
||||
const wb = wk.people.find(x => x.name === 'Bob'), wy = wk.people.find(x => x.name === 'Cy'); assert.ok(wb.points >= 8 && wb.done.some(x => x.title === 'Fix the gate'), 'what Bob did this week is listed'); assert.ok(wb.earned.includes(r5.id) && !wb.earned.includes(r50.id), 'tiers reached'); assert.equal(wb.given.length, 0);
|
||||
assert.equal((await post('/give', { user: P.b.id, reward: r50.id }, 'a')).status, 400, 'cannot give what is not earned'); assert.equal((await post('/give', { user: P.b.id, reward: r5.id }, 'b')).status, 403); assert.equal((await post('/give', { user: P.b.id, reward: r5.id })).status, 200); wk = await j(await call('/week?date=' + today, {}, 'a')); assert.deepEqual(wk.people.find(x => x.name === 'Bob').given, [r5.id]);
|
||||
assert.equal((await j(await call('/week?date=' + chores.addDays(today, -7), {}, 'a'))).people.every(x => x.points === 0), true, 'last week had no points: they start again each week');
|
||||
const rc = await j(await post('/reward', { title: 'Bake together', points: 3, for: [P.c.id] })); wk = await j(await call('/week?date=' + today, {}, 'a')); assert.ok(wk.people.find(x => x.name === 'Cy').earned.includes(rc.id), 'a reward just for Cy: Cy earns it'); assert.ok(!wk.people.find(x => x.name === 'Bob').tiers.includes(rc.id), 'and it is not on Bob\'s list'); assert.equal((await post('/reward', { title: 'x', points: 3, for: [] })).status, 400, 'someone has to be chosen'); await post('/reward', { id: rc.id, remove: true });
|
||||
assert.equal((await post('/give', { user: P.b.id, reward: r5.id, undo: true })).status, 200); assert.equal((await j(await call('/week?date=' + today, {}, 'a'))).people.find(x => x.name === 'Bob').given.length, 0);
|
||||
// delete removes its history
|
||||
assert.equal((await call('/' + once.id, { method: 'DELETE' }, 'b')).status, 403); assert.equal((await call('/' + once.id, { method: 'DELETE' })).status, 200); assert.equal((await j(await call('/summary', {}, 'a'))).people.find(x => x.name === 'Bob').points, 3);
|
||||
assert.equal((await call('/?date=nope')).status, 400);
|
||||
|
||||
Reference in new issue
Block a user