Files
hub/public/family-view.js
T
nmemmertandClaude Sonnet 5.5 870c7129b9
build-container / build (push) Successful in 4m21s
Chores: claim from the pool; admin picks who appears on the family board
- "Anyone" chores get Take it / Put back; a claimed chore is that person's for the day
- Per-person "Show on the family board" switch controls board tiles and family chore progress
- Tests and README updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:23:59 -04:00

54 lines
8.6 KiB
JavaScript

'use strict';
// ---- Family: one start page for the household: the announcement, today's events, chores, the shopping list, shared notes, chats and tasks. Each card loads on its own and
// only shows if the person has that part of the hub, so one slow or missing piece never blanks the page.
const fmCard = (id, title, href, sub) => `<section class="fmcard" id="${id}"><div class="fmh"><h2>${esc(title)}</h2>${href ? `<a href="${href}" class="fmmore">${esc(sub || 'Open')}</a>` : ''}</div><div class="fmb"><p class="empty" style="margin:6px 0">Loading…</p></div></section>`;
const fmBody = id => document.querySelector('#' + id + ' .fmb');
const fmErr = (id, e) => { const b = fmBody(id); if (b) b.innerHTML = `<p class="sub" style="margin:6px 0">${esc(e && e.message || 'Could not load this')}</p>`; };
function viewFamily() {
const h = new Date().getHours(), greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening', today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
const cards = [['cal', allowed('calendar'), fmCard('fmcal', 'Today and tomorrow', '#/calendar', 'Calendar')], ['chores', allowed('chores'), fmCard('fmch', 'Chores today', '#/chores')], ['shop', allowed('recipes'), fmCard('fmsh', 'Shopping list', '#/recipes/shop')],
['notes', allowed('notes'), fmCard('fmnt', 'Family notes and lists', '#/notes')], ['chat', allowed('chat'), fmCard('fmct', 'Chat', '#/chat')], ['tasks', allowed('tasks'), fmCard('fmtk', 'Your tasks', '#/tasks')]].filter(c => c[1]);
$('#view').innerHTML = `<div class="wrap fmwrap"><header class="homehead"><div><h1>${greet}${ME ? ', ' + esc(ME.name.split(' ')[0]) : ''}</h1><p class="sub">${today}</p></div><div class="homeact">${inboxBell()}</div></header>
<div id="annc"></div><div class="fmgrid">${cards.map(c => c[2]).join('') || '<p class="empty">Nothing here yet. An admin can turn on Calendar, Chores, Notes, Chat and more for you.</p>'}</div></div>`;
inboxWire();
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
const done = new Set(cards.map(c => c[0])), run = (k, f) => { if (done.has(k)) f().catch(e => fmErr({ cal: 'fmcal', chores: 'fmch', shop: 'fmsh', notes: 'fmnt', chat: 'fmct', tasks: 'fmtk' }[k], e)); };
run('cal', fmEvents); run('chores', fmChores); run('shop', fmShopping); run('notes', fmNotes); run('chat', fmChat); run('tasks', fmTasks);
}
async function fmEvents() {
const d0 = new Date(), t0 = crYmd(d0), t1 = crShift(t0, 1), from = new Date(t0 + 'T00:00:00').getTime(), to = new Date(t1 + 'T23:59:59').getTime();
const r = await jfetch('/api/cal/events?' + new URLSearchParams({ from, to, fd: t0, td: t1 })), b = fmBody('fmcal'); if (!b) return;
const day = (ymdS, e) => e.allDay ? e.start <= ymdS && e.end > ymdS : crYmd(new Date(e.start)) === ymdS;
const section = (label, ymdS) => { const l = r.events.filter(e => day(ymdS, e)).sort((a, c) => (c.allDay - a.allDay) || (a.allDay ? 0 : a.start - c.start)); return `<div class="fmday"><small style="display:block">${label}</small>${l.map(e => `<div class="fmrow"><span class="fmdot" style="background:${esc(e.color || 'var(--accent)')}"></span><span class="grow"><b>${esc(e.title)}</b></span><small>${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</small></div>`).join('') || '<small class="sub" style="display:block;text-transform:none;letter-spacing:0;font-size:13px;margin:4px 0">Nothing</small>'}</div>`; };
b.innerHTML = section('Today', t0) + section('Tomorrow', t1);
}
async function fmChores() {
const d = await crApi('/?date=' + crYmd(new Date())), b = fmBody('fmch'); if (!b) return; const me = d.me.id;
const per = d.people.filter(p => p.board !== false).map(p => { const l = d.board.filter(i => i.user === p.id), dn = l.filter(i => i.done).length; return { p, l, dn }; }).filter(x => x.l.length), any = d.board.filter(i => i.user === null);
const mine = d.board.filter(i => i.user === me || i.user === null).filter(i => !i.done);
b.innerHTML = `${mine.length ? mine.slice(0, 5).map(i => `<label class="critem"><input type="checkbox" data-c="${esc(i.chore)}" data-u="${esc(i.user || '')}"><span class="grow"><b>${esc(i.title)}</b></span>${i.points ? `<em class="crpts">${i.points} pts</em>` : ''}</label>`).join('') : '<p class="empty" style="margin:6px 0">Nothing left for you today. 🎉</p>'}
${per.length ? `<div class="fmprog">${per.map(x => `<div class="fmpr"><span class="sgav" style="background:${sgColor(x.p.id)};width:26px;height:26px;font-size:12px">${esc(sgInitial(x.p.name))}</span><span class="grow"><small>${esc(x.p.name)} · ${x.dn} of ${x.l.length}</small><div class="crbar"><i style="width:${Math.round(x.dn / x.l.length * 100)}%"></i></div></span></div>`).join('')}</div>` : ''}${any.length ? `<small class="sub">${any.filter(i => !i.done).length} for anyone</small>` : ''}`;
b.querySelectorAll('.critem input').forEach(c => c.onchange = async () => { try { await crPost('/done', { chore: c.dataset.c, date: crYmd(new Date()), user: c.dataset.u || undefined }); toast('Nice work!'); fmChores(); } catch (e) { toast(e.message); c.checked = false; } });
}
async function fmShopping() {
const b0 = fmBody('fmsh'); const st = await jfetch('/api/recipes/status'); if (!st.enabled) { if (b0) b0.innerHTML = '<p class="sub" style="margin:6px 0">The shopping list is not set up yet.</p>'; return; }
const l = await jfetch('/api/recipes/shopping'), b = fmBody('fmsh'); if (!b) return; const open = l.items.filter(i => !i.checked);
b.innerHTML = `${open.slice(0, 8).map(i => `<label class="critem"><input type="checkbox" data-id="${esc(i.id)}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty" style="margin:6px 0">Nothing to buy.</p>'}${open.length > 8 ? `<small class="sub">+ ${open.length - 8} more</small>` : ''}
<form class="bar" id="fmshf" style="margin:8px 0 0;gap:8px;flex-wrap:nowrap"><input type="text" id="fmshi" placeholder="Add an item" maxlength="200" autocomplete="off" style="flex:1"><button class="btn ghost">Add</button></form>`;
b.querySelectorAll('input[data-id]').forEach(c => c.onchange = async () => { try { await jfetch('/api/recipes/shopping/check', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: c.dataset.id, checked: true }) }); fmShopping(); } catch (e) { toast(e.message); c.checked = false; } });
b.querySelector('#fmshf').onsubmit = async e => { e.preventDefault(); const i = b.querySelector('#fmshi'), t = i.value.trim(); if (!t) return; try { await jfetch('/api/recipes/shopping/add', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: t }) }); fmShopping(); } catch (x) { toast(x.message); } };
}
async function fmNotes() {
const r = await jfetch('/api/notes'), b = fmBody('fmnt'); if (!b) return; const l = r.notes.filter(n => !n.archived && n.share !== 'me').slice(0, 4);
b.innerHTML = l.map(n => `<a class="fmrow" href="#/notes/${esc(n.id)}" style="text-decoration:none;color:inherit"><span class="fmdot" style="background:${NT_COLORS[n.color] || 'var(--line)'}"></span><span class="grow"><b>${esc(n.title || (n.type === 'list' ? 'Checklist' : 'Note'))}${n.pinned ? ' 📌' : ''}</b><small>${n.type === 'list' ? ntDone(n) + ' of ' + n.items.length + ' done' + (n.items.find(i => !i.done) ? ' · next: ' + esc(n.items.find(i => !i.done).text) : '') : esc(n.body.slice(0, 70))}</small></span></a>`).join('') || '<p class="empty" style="margin:6px 0">No shared notes yet.</p>';
}
async function fmChat() {
const r = await jfetch('/api/chat/convs'), b = fmBody('fmct'); if (!b) return; const l = r.convs.filter(c => !c.archived).slice(0, 4);
b.innerHTML = l.map(c => `<a class="fmrow" href="#/chat/${encodeURIComponent(c.id)}" style="text-decoration:none;color:inherit"><span class="sgav" style="background:${sgColor(c.id)};width:30px;height:30px;font-size:13px">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b>${esc(c.name)}</b><small>${esc(c.preview || '')}</small></span>${c.unread ? `<i class="nbadge" style="position:static">${c.unread}</i>` : ''}</a>`).join('') || '<p class="empty" style="margin:6px 0">No chats yet.</p>';
}
async function fmTasks() {
const r = await jfetch('/api/tasks/upcoming?today=' + crYmd(new Date())), b = fmBody('fmtk'); if (!b) return;
b.innerHTML = r.tasks.slice(0, 5).map(t => `<a class="fmrow" href="#/tasks" style="text-decoration:none;color:inherit"><span class="grow"><b>${esc(t.title)}</b></span><small style="${t.due < crYmd(new Date()) ? 'color:var(--danger)' : ''}">${t.due === crYmd(new Date()) ? 'Today' : esc(t.due)}</small></a>`).join('') || '<p class="empty" style="margin:6px 0">Nothing due this week. 🎉</p>';
}