Devotion: reading plan with check-ins, and a family prayer list

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 09:50:23 -04:00
1 parent d7ee6f1339
commit b0ca8daa2e
4 files changed
+89 -6

No files matched your search

+26 -2
View File
@@ -4,7 +4,7 @@ const fs = require('fs'), path = require('path');
const users = require('./users');
const file = () => path.join(users.dir(), 'bible.json');
const load = () => { try { const o = JSON.parse(fs.readFileSync(file(), 'utf8')); return { pos: o.pos || null, marks: Array.isArray(o.marks) ? o.marks : [] }; } catch { return { pos: null, marks: [] }; } };
const load = () => { try { const o = JSON.parse(fs.readFileSync(file(), 'utf8')); return { pos: o.pos || null, marks: Array.isArray(o.marks) ? o.marks : [], plan: o.plan || null }; } catch { return { pos: null, marks: [], plan: null }; } };
const save = o => { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o), { mode: 0o600 }); fs.renameSync(tmp, f); };
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const clip = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
@@ -23,9 +23,33 @@ function mergeMarks(have, sent) {
}
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 2e6) return null; c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { return undefined; } };
// ---- Devotion: a one-year reading plan with a check-in per day (kept per person), and a family prayer list everyone can add to and mark answered.
const crypto = require('crypto');
const PRAYERS = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'prayers.json');
const loadPrayers = () => { try { const a = JSON.parse(fs.readFileSync(PRAYERS(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
const savePrayers = a => { const f = PRAYERS(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(a), { mode: 0o600 }); fs.renameSync(tmp, f); };
const prayerView = (x, me) => ({ id: x.id, text: x.text, by: x.by, byName: (users.get(x.by) || {}).name || 'Someone', at: x.at, answered: !!x.answered, answeredAt: x.answeredAt || 0, mine: x.by === me.id });
const DAY = /^\d{4}-\d{2}-\d{2}$/;
async function devotion(req, res, p) {
const me = users.current(); if (!me) return json(res, 401, { error: 'Sign in' });
if (p === '/prayers' && req.method === 'GET') return json(res, 200, loadPrayers().map(x => prayerView(x, me)));
if (req.method !== 'POST') return json(res, 404, { error: 'Not found' });
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' });
if (p === '/plan') { const s = load(); if (b.stop) s.plan = null; else if (DAY.test(b.start || '')) s.plan = { start: b.start, done: [] }; else return json(res, 400, { error: 'Pick a start date' }); save(s); return json(res, 200, { plan: s.plan }); }
if (p === '/plan/check') { const s = load(), d = Math.round(+b.day); if (!s.plan || !(d >= 0 && d < 366)) return json(res, 400, { error: 'No plan' }); const set = new Set(s.plan.done); if (b.on) set.add(d); else set.delete(d); s.plan.done = [...set].sort((x, y) => x - y); save(s); return json(res, 200, { plan: s.plan }); }
const l = loadPrayers();
if (p === '/prayers') { const text = clip(b.text, 500); if (!text) return json(res, 400, { error: 'Write the prayer request' }); if (l.length >= 500) return json(res, 400, { error: 'The prayer list is full' }); l.unshift({ id: crypto.randomBytes(6).toString('hex'), text, by: me.id, at: Date.now() }); savePrayers(l); return json(res, 200, l.map(x => prayerView(x, me))); }
const x = l.find(y => y.id === b.id); if (!x) return json(res, 404, { error: 'That request is gone' });
if (p === '/prayers/answer') { x.answered = !!b.on; x.answeredAt = b.on ? Date.now() : 0; savePrayers(l); return json(res, 200, l.map(y => prayerView(y, me))); }
if (p === '/prayers/delete') { if (x.by !== me.id && me.role !== 'admin') return json(res, 403, { error: 'Only the person who wrote it can remove it' }); savePrayers(l.filter(y => y !== x)); return json(res, 200, l.filter(y => y !== x).map(y => prayerView(y, me))); }
return json(res, 404, { error: 'Not found' });
}
async function handle(req, res) {
const p = new URL(req.url, 'http://x').pathname.slice('/api/bible'.length) || '/';
if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del) }); }
if (p === '/plan' || p.startsWith('/plan/') || p.startsWith('/prayers')) return devotion(req, res, p);
if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del), plan: s.plan }); }
if (p === '/pos' && req.method === 'POST') { // where you are reading; the newest report wins
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); const s = load();
if (BOOK.test(b.b || '') && (+b.t || 0) >= ((s.pos || {}).t || 0)) s.pos = { b: b.b, c: Math.max(1, Math.round(+b.c) || 1), v: Math.max(0, Math.round(+b.v) || 0), t: +b.t || Date.now() };