Chores, Family page, genre search, child accounts, wider Search and Notifications center
build-container / build (push) Successful in 4m6s

- Chores tab with repeats, points and a week/month board
- Family page: calendar, chores, shopping, notes, chat and tasks on one start page
- Genre search in the Store, Free Books and LibriVox
- Child accounts: safe content, daily limit and bedtime, ask a parent
- Search now covers notes, chat, contacts, chores, recipes and your books
- Notifications center: bell with a per-person list of chat, reminders and announcements
- Roadmap, README and tests updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 11:48:55 -04:00
1 parent cf292e3bc7
commit 0383da87f5
38 files changed
+720 -79

No files matched your search

+9 -7
View File
@@ -9,6 +9,7 @@ const users = require('./users');
const tasks = require('./tasks');
const cal = require('./cal');
const mail = require('./mail');
const inbox = require('./inbox');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > 20000) { no(new Error('Too large')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
@@ -34,7 +35,8 @@ function localNow(tz, at = new Date()) {
return { day: `${p.year}-${p.month}-${p.day}`, hm: `${p.hour}:${p.minute}` };
}
async function send(dir, st, payload) {
async function send(dir, st, uid, payload) {
if (uid) inbox.add(uid, { kind: 'remind', title: payload.title, body: payload.body, url: payload.url, tag: payload.tag }); // (reminders also land in the Notifications list)
keys();
let changed = false;
await Promise.all(st.subs.map(async s => {
@@ -61,7 +63,7 @@ async function tick(now = new Date(), force = false) {
const due = (await tasks.allTasks()).filter(t => !t.done && t.due && t.due <= lt.day);
if (due.length) {
const over = due.filter(t => t.due < lt.day).length;
await send(dir, st, { title: due.length === 1 ? due[0].title : due.length + ' tasks need attention', body: due.length === 1 ? (over ? 'Overdue' : 'Due today') : due.slice(0, 3).map(t => t.title).join(', ') + (due.length > 3 ? '…' : '') + (over ? ` (${over} overdue)` : ''), url: '#/tasks', tag: 'tasks-' + lt.day });
await send(dir, st, u.id, { title: due.length === 1 ? due[0].title : due.length + ' tasks need attention', body: due.length === 1 ? (over ? 'Overdue' : 'Due today') : due.slice(0, 3).map(t => t.title).join(', ') + (due.length > 3 ? '…' : '') + (over ? ` (${over} overdue)` : ''), url: '#/tasks', tag: 'tasks-' + lt.day });
}
} catch (e) { console.warn('[push] tasks check failed:', e.message); }
}
@@ -71,7 +73,7 @@ async function tick(now = new Date(), force = false) {
const bucket = ss.days <= 0 ? 0 : [1, 3, 7, 14, 30].find(t => ss.days <= t), key = `mailsecret:${ss.expires}:${bucket}`;
if (!st.sent.includes(key)) {
st.sent.push(key); dirty = true;
await send(dir, st, { title: ss.days <= 0 ? 'Mail sign-in has expired' : `Mail sign-in expires in ${ss.days} day${ss.days === 1 ? '' : 's'}`, body: 'Make a new client secret in Microsoft Entra and paste it in Settings -> Microsoft app.', url: '#/settings', tag: 'mailsecret' });
await send(dir, st, u.id, { title: ss.days <= 0 ? 'Mail sign-in has expired' : `Mail sign-in expires in ${ss.days} day${ss.days === 1 ? '' : 's'}`, body: 'Make a new client secret in Microsoft Entra and paste it in Settings -> Microsoft app.', url: '#/settings', tag: 'mailsecret' });
}
}
}
@@ -83,7 +85,7 @@ async function tick(now = new Date(), force = false) {
const fresh = now2.filter(m => !seen.includes(m.id));
if (fresh.length) {
st.mailSeen = seen.concat(fresh.map(m => m.id)).slice(-200); dirty = true;
await send(dir, st, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail' });
await send(dir, st, u.id, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail' });
}
}
} catch (e) { console.warn('[push] mail check failed:', e.message); }
@@ -99,7 +101,7 @@ async function tick(now = new Date(), force = false) {
if (st.sent.includes(key)) continue;
st.sent.push(key); dirty = true;
const mins = Math.max(1, Math.round((e.start - from) / 6e4));
await send(dir, st, { title: e.title, body: `Starts in ${mins} min${e.location ? ' · ' + e.location : ''}`, url: '#/calendar', tag: key });
await send(dir, st, u.id, { title: e.title, body: `Starts in ${mins} min${e.location ? ' · ' + e.location : ''}`, url: '#/calendar', tag: key });
}
if (st.sent.length > 300) { st.sent = st.sent.slice(-200); dirty = true; }
} catch (e) { console.warn('[push] events check failed:', e.message); }
@@ -137,11 +139,11 @@ async function handle(req, res) {
}
if (p === '/api/push/test') {
if (!st.subs.length) return json(res, 400, { error: 'No device is signed up for reminders yet' });
await send(dir, st, { title: 'NE Hub reminders are on', body: 'You will see task and event reminders here.', url: '#/home', tag: 'test' });
await send(dir, st, null, { title: 'NE Hub reminders are on', body: 'You will see task and event reminders here.', url: '#/home', tag: 'test' });
return json(res, 200, { ok: true });
}
json(res, 404, { error: 'Not found' });
}
/** A notification for one person's devices (a chat message, say), unless they turned that kind off. */
async function notify(uid, payload) { const dir = users.dirOf(uid), st = load(dir); if (!st.subs.length || st.chat === false) return; await send(dir, st, payload); }
async function notify(uid, payload) { const dir = users.dirOf(uid), st = load(dir); if (!st.subs.length || st.chat === false) return; await send(dir, st, null, payload); }
module.exports = { handle, start, tick, localNow, notify };