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:
1 parent
d7ee6f1339
commit
b0ca8daa2e
4 files changed
+89
-6
No files matched your search
@@ -4,7 +4,7 @@ const fs = require('fs'), path = require('path');
|
|||||||
const users = require('./users');
|
const users = require('./users');
|
||||||
|
|
||||||
const file = () => path.join(users.dir(), 'bible.json');
|
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 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 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);
|
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; } };
|
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) {
|
async function handle(req, res) {
|
||||||
const p = new URL(req.url, 'http://x').pathname.slice('/api/bible'.length) || '/';
|
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
|
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();
|
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() };
|
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() };
|
||||||
|
|||||||
@@ -68,13 +68,14 @@ async function viewBible(args = []) {
|
|||||||
if (!location.hash.startsWith('#/bible')) return;
|
if (!location.hash.startsWith('#/bible')) return;
|
||||||
try { const s = await bbApi('/state'); bbSt = s; } catch {}
|
try { const s = await bbApi('/state'); bbSt = s; } catch {}
|
||||||
if (args[0] === 'marks') return bbMarks();
|
if (args[0] === 'marks') return bbMarks();
|
||||||
|
if (args[0] === 'today' && typeof bbToday === 'function') return bbToday();
|
||||||
let b = args[0] && bbBook(args[0]) ? args[0] : null, c = +args[1] || 1, vs = +args[2] || 0;
|
let b = args[0] && bbBook(args[0]) ? args[0] : null, c = +args[1] || 1, vs = +args[2] || 0;
|
||||||
if (!b) { const mine = (() => { try { return JSON.parse(localStorage.getItem(nk('bb-last'))); } catch { return null; } })(), p = mine && (!bbSt.pos || mine.t > bbSt.pos.t) ? mine : bbSt.pos; b = p && bbBook(p.b) ? p.b : 'JHN'; c = p ? p.c : 1; vs = 0; return location.replace('#/bible/' + b + '/' + c); }
|
if (!b) { const mine = (() => { try { return JSON.parse(localStorage.getItem(nk('bb-last'))); } catch { return null; } })(), p = mine && (!bbSt.pos || mine.t > bbSt.pos.t) ? mine : bbSt.pos; b = p && bbBook(p.b) ? p.b : 'JHN'; c = p ? p.c : 1; vs = 0; return location.replace('#/bible/' + b + '/' + c); }
|
||||||
const book = bbBook(b); c = Math.min(book.chapters.length, Math.max(1, c)); bbCur = { b, c }; bbSel = new Set();
|
const book = bbBook(b); c = Math.min(book.chapters.length, Math.max(1, c)); bbCur = { b, c }; bbSel = new Set();
|
||||||
const P = bbPrefs(), name = book.name, last = c === book.chapters.length, order = bb.books.findIndex(x => x.id === b);
|
const P = bbPrefs(), name = book.name, last = c === book.chapters.length, order = bb.books.findIndex(x => x.id === b);
|
||||||
const prev = c > 1 ? [b, c - 1] : order > 0 ? [bb.books[order - 1].id, bb.books[order - 1].chapters.length] : null, next = !last ? [b, c + 1] : order < 65 ? [bb.books[order + 1].id, 1] : null, href = x => '#/bible/' + x[0] + '/' + x[1];
|
const prev = c > 1 ? [b, c - 1] : order > 0 ? [bb.books[order - 1].id, bb.books[order - 1].chapters.length] : null, next = !last ? [b, c + 1] : order < 65 ? [bb.books[order + 1].id, 1] : null, href = x => '#/bible/' + x[0] + '/' + x[1];
|
||||||
v.innerHTML = `<div class="wrap bbwrap"><div class="bbbar"><button class="btn ghost" id="bbpick" aria-label="Choose a book and chapter">${icon('bible')}<b>${esc(name)} ${c}</b></button><span class="grow"></span>
|
v.innerHTML = `<div class="wrap bbwrap"><div class="bbbar"><button class="btn ghost" id="bbpick" aria-label="Choose a book and chapter">${icon('bible')}<b>${esc(name)} ${c}</b></button><span class="grow"></span>
|
||||||
<button class="ibtn" id="bbsrch" aria-label="Search the Bible" title="Search">${icon('search')}</button><button class="ibtn" id="bbmk" aria-label="My highlights and notes" title="My highlights and notes">${icon('bookmark')}</button><button class="ibtn" id="bbaa" aria-label="Text size" title="Text size">${icon('font')}</button></div>
|
<button class="ibtn" id="bbsrch" aria-label="Search the Bible" title="Search">${icon('search')}</button><button class="ibtn" id="bbtd" aria-label="Devotion: verse for today, reading plan, prayer list" title="Devotion">${icon('heart')}</button><button class="ibtn" id="bbmk" aria-label="My highlights and notes" title="My highlights and notes">${icon('bookmark')}</button><button class="ibtn" id="bbaa" aria-label="Text size" title="Text size">${icon('font')}</button></div>
|
||||||
<article id="bbtext" class="bbtext ${P.serif ? 'serif' : ''}" style="font-size:${P.size}px">${bbChapterHtml(book, c)}</article>
|
<article id="bbtext" class="bbtext ${P.serif ? 'serif' : ''}" style="font-size:${P.size}px">${bbChapterHtml(book, c)}</article>
|
||||||
<div class="bbnav">${prev ? `<a class="btn ghost" href="${href(prev)}">${icon('back')}${esc(bbBook(prev[0]).name)} ${prev[1]}</a>` : '<span></span>'}${next ? `<a class="btn ghost" href="${href(next)}">${esc(bbBook(next[0]).name)} ${next[1]}${icon('chev')}</a>` : '<span></span>'}</div>
|
<div class="bbnav">${prev ? `<a class="btn ghost" href="${href(prev)}">${icon('back')}${esc(bbBook(prev[0]).name)} ${prev[1]}</a>` : '<span></span>'}${next ? `<a class="btn ghost" href="${href(next)}">${esc(bbBook(next[0]).name)} ${next[1]}${icon('chev')}</a>` : '<span></span>'}</div>
|
||||||
<p class="sub" style="text-align:center;margin:18px 0 0;font-size:12px">Berean Standard Bible (BSB), public domain · <a href="https://berean.bible/" target="_blank" rel="noopener" style="color:var(--accent)">berean.bible</a></p><div class="rdsel bbsel" id="bbsel" hidden></div></div>`;
|
<p class="sub" style="text-align:center;margin:18px 0 0;font-size:12px">Berean Standard Bible (BSB), public domain · <a href="https://berean.bible/" target="_blank" rel="noopener" style="color:var(--accent)">berean.bible</a></p><div class="rdsel bbsel" id="bbsel" hidden></div></div>`;
|
||||||
@@ -84,7 +85,7 @@ async function viewBible(args = []) {
|
|||||||
const fn = e.target.closest('.fn'); if (fn) { const n = bbNote(b, c, +fn.dataset.v, +fn.dataset.i); if (n) openSheet(bbRef(b, c, +fn.dataset.v), '', `<p style="font-size:17px;line-height:1.5">${esc(n)}</p>`); return; }
|
const fn = e.target.closest('.fn'); if (fn) { const n = bbNote(b, c, +fn.dataset.v, +fn.dataset.i); if (n) openSheet(bbRef(b, c, +fn.dataset.v), '', `<p style="font-size:17px;line-height:1.5">${esc(n)}</p>`); return; }
|
||||||
const el = e.target.closest('.vs'); if (!el) { bbSel.clear(); bbPaint(root); return; } const n = +el.dataset.v; if (bbSel.has(n)) bbSel.delete(n); else bbSel.add(n); bbPaint(root);
|
const el = e.target.closest('.vs'); if (!el) { bbSel.clear(); bbPaint(root); return; } const n = +el.dataset.v; if (bbSel.has(n)) bbSel.delete(n); else bbSel.add(n); bbPaint(root);
|
||||||
});
|
});
|
||||||
$('#bbpick').onclick = () => bbPicker(); $('#bbsrch').onclick = () => bbSearch(); $('#bbmk').onclick = () => { location.hash = '#/bible/marks'; }; $('#bbaa').onclick = () => bbTextSheet(root);
|
$('#bbpick').onclick = () => bbPicker(); $('#bbsrch').onclick = () => bbSearch(); $('#bbmk').onclick = () => { location.hash = '#/bible/marks'; }; $('#bbtd').onclick = () => { location.hash = '#/bible/today'; }; $('#bbaa').onclick = () => bbTextSheet(root);
|
||||||
let x0 = 0, y0 = 0, t0 = 0; root.addEventListener('touchstart', e => { x0 = e.changedTouches[0].clientX; y0 = e.changedTouches[0].clientY; t0 = Date.now(); }, { passive: true });
|
let x0 = 0, y0 = 0, t0 = 0; root.addEventListener('touchstart', e => { x0 = e.changedTouches[0].clientX; y0 = e.changedTouches[0].clientY; t0 = Date.now(); }, { passive: true });
|
||||||
root.addEventListener('touchend', e => { const dx = e.changedTouches[0].clientX - x0, dy = e.changedTouches[0].clientY - y0; if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 2 && Date.now() - t0 < 600 && !bbSel.size) { const to = dx < 0 ? next : prev; if (to) location.hash = href(to); } });
|
root.addEventListener('touchend', e => { const dx = e.changedTouches[0].clientX - x0, dy = e.changedTouches[0].clientY - y0; if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 2 && Date.now() - t0 < 600 && !bbSel.size) { const to = dx < 0 ? next : prev; if (to) location.hash = href(to); } });
|
||||||
bbCur.next = next && href(next); bbCur.prev = prev && href(prev);
|
bbCur.next = next && href(next); bbCur.prev = prev && href(prev);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use strict';
|
||||||
|
// ---- Devotion (inside Bible): a verse for today, a read-through-the-Bible-in-a-year plan with a check-in each day, and a prayer list the whole family shares.
|
||||||
|
const DV_VERSES = ['JHN 3:16', 'PSA 23:1-3', 'PRO 3:5-6', 'ROM 8:28', 'PHP 4:6-7', 'ISA 41:10', 'JER 29:11', 'MAT 6:33', 'PSA 46:1', 'JOS 1:9', 'ROM 12:2', 'GAL 5:22-23', '1CO 13:4-7', 'EPH 2:8-9', 'PSA 119:105', 'MAT 11:28-30', 'COL 3:23', 'PSA 27:1', '2TI 1:7', 'HEB 11:1', 'PSA 121:1-2', 'MIC 6:8', 'JHN 14:27', 'ROM 5:8', '1JN 4:19', 'PSA 37:4-5', 'LAM 3:22-23', 'ISA 40:31', 'MAT 5:14-16', 'JAS 1:5', '1PE 5:7', 'PSA 34:8', 'DEU 31:6', 'PRO 22:6', 'EPH 6:1-3', '1TH 5:16-18', 'PSA 139:13-14', 'JHN 15:12-13', 'ROM 15:13', 'NUM 6:24-26', 'PSA 103:1-2', 'HEB 13:5-6', 'PRO 16:3', '2CO 12:9', 'JHN 1:1-5', 'PSA 100:4-5', 'MAT 28:19-20', 'ZEP 3:17', 'PSA 51:10', 'COL 3:12-14'];
|
||||||
|
const dvDay = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
const dvDiff = (a, b) => Math.round((new Date(b + 'T12:00:00') - new Date(a + 'T12:00:00')) / 864e5);
|
||||||
|
/** The text of verses v..ve in a chapter, footnote markers left out. */
|
||||||
|
function dvText(book, c, v, ve) {
|
||||||
|
const out = []; for (const b of book.chapters[c - 1]) if (b[0] === 'v' && b[1] >= v && b[1] <= (ve || v)) out.push(b[2].map(p => typeof p === 'string' ? p : Array.isArray(p) ? p[1] : '').join('').replace(/\s+/g, ' ').trim());
|
||||||
|
return out.join(' ');
|
||||||
|
}
|
||||||
|
function dvVerseOfDay() {
|
||||||
|
const now = new Date(), n = Math.floor((now - new Date(now.getFullYear(), 0, 0)) / 864e5), m = /^(\w{3}) (\d+):(\d+)(?:-(\d+))?$/.exec(DV_VERSES[n % DV_VERSES.length]), book = bbBook(m[1]);
|
||||||
|
return book ? { ref: bbRef(m[1], +m[2], +m[3], m[4] ? +m[4] : 0), text: dvText(book, +m[2], +m[3], m[4] ? +m[4] : 0), link: `#/bible/${m[1]}/${m[2]}/${m[3]}` } : null;
|
||||||
|
}
|
||||||
|
/** Every chapter of the Bible in order, split into 365 daily readings. */
|
||||||
|
function dvReadings(day) {
|
||||||
|
const all = []; for (const b of bb.books) for (let c = 1; c <= b.chapters.length; c++) all.push([b.id, c]);
|
||||||
|
const from = Math.floor(day * all.length / 365), to = Math.floor((day + 1) * all.length / 365); return all.slice(from, to);
|
||||||
|
}
|
||||||
|
async function bbToday() {
|
||||||
|
const v = $('#view'); let plan = bbSt.plan || null, prayers = [], showAnswered = false;
|
||||||
|
try { prayers = await bbApi('/prayers'); } catch {}
|
||||||
|
const draw = () => {
|
||||||
|
const vod = dvVerseOfDay(), today = dvDay(), idx = plan ? dvDiff(plan.start, today) : -1, day = Math.min(364, Math.max(0, idx)), done = new Set(plan ? plan.done : []);
|
||||||
|
let streak = 0; if (plan) for (let d = Math.min(idx, 364) - (done.has(Math.min(idx, 364)) ? 0 : 1); d >= 0 && done.has(d); d--) streak++;
|
||||||
|
const open = prayers.filter(p => !p.answered), answered = prayers.filter(p => p.answered);
|
||||||
|
const prayRow = p => `<div class="row"><span class="grow"><span style="display:block;white-space:normal">${esc(p.text)}</span><small>${esc(p.byName)} · ${new Date(p.answered ? p.answeredAt : p.at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}${p.answered ? ' · answered' : ''}</small></span><button class="ibtn" data-pa="${p.id}" data-on="${p.answered ? '' : '1'}" aria-label="${p.answered ? 'Mark as still praying' : 'Mark as answered'}" title="${p.answered ? 'Still praying' : 'Answered'}">${icon('check')}</button>${p.mine || (typeof ME !== 'undefined' && ME && ME.role === 'admin') ? `<button class="ibtn" data-pd="${p.id}" aria-label="Remove" title="Remove">${icon('trash')}</button>` : ''}</div>`;
|
||||||
|
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Devotion</h1><a class="btn ghost" href="#/bible">${icon('bible')}Bible</a></div>
|
||||||
|
${vod ? `<h2>Verse for today</h2><a class="card dvverse" href="${vod.link}"><p>“${esc(vod.text)}”</p><small>${esc(vod.ref)} · BSB</small></a>` : ''}
|
||||||
|
<h2>Reading plan</h2>${plan ? `<div class="card" style="max-width:none"><b>Day ${day + 1} of 365</b><span class="sub"> · ${done.size} day${done.size === 1 ? '' : 's'} done${streak > 1 ? ` · ${streak} in a row` : ''}</span>
|
||||||
|
<div class="dvread">${idx < 0 ? `<p class="sub">Your plan starts ${esc(plan.start)}.</p>` : dvReadings(day).map(([b, c]) => `<a class="btn ghost" href="#/bible/${b}/${c}">${esc(bbBook(b).name)} ${c}</a>`).join('')}</div>
|
||||||
|
${idx >= 0 ? `<div class="bar"><button class="btn${done.has(day) ? ' ghost' : ''}" id="dvck">${icon('check')}${done.has(day) ? 'Done today' : 'Mark today as read'}</button><button class="btn ghost" id="dvstop">Stop plan</button></div>` : ''}</div>`
|
||||||
|
: `<div class="card" style="max-width:none"><p class="sub" style="margin-top:0">Read the whole Bible in a year, a few chapters a day, and check in each day.</p><div class="bar"><button class="btn" id="dvstart">Start today</button></div></div>`}
|
||||||
|
<h2>Family prayer list</h2><form class="bar" id="dvpf" style="flex-wrap:nowrap"><input type="text" id="dvpt" placeholder="Add a prayer request" maxlength="500" autocomplete="off" style="flex:1"><button class="btn">Add</button></form>
|
||||||
|
<div class="list">${open.map(prayRow).join('') || '<div class="row"><span class="grow"><small>Nothing yet. Add what the family is praying about.</small></span></div>'}</div>
|
||||||
|
${answered.length ? `<div class="bar"><button class="btn ghost" id="dvans">${showAnswered ? 'Hide' : 'Show'} answered (${answered.length})</button></div>${showAnswered ? `<div class="list">${answered.map(prayRow).join('')}</div>` : ''}` : ''}</div>`;
|
||||||
|
const save = async (fn) => { try { await fn(); draw(); } catch (e) { toast(e.message); } };
|
||||||
|
const st = $('#dvstart'); if (st) st.onclick = () => save(async () => { plan = (await bbApi('/plan', bbOpt('POST', { start: dvDay() }))).plan; bbSt.plan = plan; });
|
||||||
|
const sp = $('#dvstop'); if (sp) sp.onclick = () => confirmSheet('Stop the reading plan?', 'Your check-ins are cleared.', 'Stop plan', () => save(async () => { await bbApi('/plan', bbOpt('POST', { stop: true })); plan = bbSt.plan = null; }));
|
||||||
|
const ck = $('#dvck'); if (ck) ck.onclick = () => save(async () => { plan = (await bbApi('/plan/check', bbOpt('POST', { day, on: !done.has(day) }))).plan; bbSt.plan = plan; });
|
||||||
|
$('#dvpf').onsubmit = e => { e.preventDefault(); const t = $('#dvpt').value.trim(); if (t) save(async () => { prayers = await bbApi('/prayers', bbOpt('POST', { text: t })); }); };
|
||||||
|
v.querySelectorAll('[data-pa]').forEach(b => b.onclick = () => save(async () => { prayers = await bbApi('/prayers/answer', bbOpt('POST', { id: b.dataset.pa, on: !!b.dataset.on })); }));
|
||||||
|
v.querySelectorAll('[data-pd]').forEach(b => b.onclick = () => save(async () => { prayers = await bbApi('/prayers/delete', bbOpt('POST', { id: b.dataset.pd })); }));
|
||||||
|
const an = $('#dvans'); if (an) an.onclick = () => { showAnswered = !showAnswered; draw(); };
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
}
|
||||||
+13
-2
@@ -6,15 +6,26 @@ const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }),
|
|||||||
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => bible.handle(req, res))).listen(0, async () => {
|
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => bible.handle(req, res))).listen(0, async () => {
|
||||||
const base = 'http://127.0.0.1:' + hub.address().port + '/api/bible', j = r => r.json(), post = (p, o, h = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: h });
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/bible', j = r => r.json(), post = (p, o, h = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: h });
|
||||||
try {
|
try {
|
||||||
assert.deepEqual(await j(await fetch(base + '/state')), { pos: null, marks: [] });
|
assert.deepEqual(await j(await fetch(base + '/state')), { pos: null, marks: [], plan: null });
|
||||||
await post('/pos', { b: 'JHN', c: 3, v: 16, t: 100 }); let r = await j(await post('/pos', { b: 'GEN', c: 1, t: 50 })); assert.equal(r.b, 'JHN', 'the newest place wins');
|
await post('/pos', { b: 'JHN', c: 3, v: 16, t: 100 }); let r = await j(await post('/pos', { b: 'GEN', c: 1, t: 50 })); assert.equal(r.b, 'JHN', 'the newest place wins');
|
||||||
assert.deepEqual((await j(await post('/pos', { b: '../x', c: 1, t: 999 }))).b, 'JHN', 'a bad book is ignored'); assert.equal((await j(await fetch(base + '/state'))).pos.c, 3);
|
assert.deepEqual((await j(await post('/pos', { b: '../x', c: 1, t: 999 }))).b, 'JHN', 'a bad book is ignored'); assert.equal((await j(await fetch(base + '/state'))).pos.c, 3);
|
||||||
r = await j(await post('/marks', [{ id: 'm1', t: 10, type: 'hl', b: 'JHN', c: 3, v: 16, color: 'green', text: 'For God so loved the world' }])); assert.equal(r.length, 1);
|
r = await j(await post('/marks', [{ id: 'm1', t: 10, type: 'hl', b: 'JHN', c: 3, v: 16, color: 'green', text: 'For God so loved the world' }])); assert.equal(r.length, 1);
|
||||||
r = await j(await post('/marks', [{ id: 'm2', t: 11, type: 'note', b: 'PSA', c: 23, v: 1, ve: 3, note: 'my shepherd' }, { id: 'm1', t: 5, type: 'hl', b: 'JHN', c: 3, v: 16, text: 'stale edit' }, { id: '../bad', t: 1, b: 'JHN' }, { id: 'm3', t: 1, b: 'nope' }]));
|
r = await j(await post('/marks', [{ id: 'm2', t: 11, type: 'note', b: 'PSA', c: 23, v: 1, ve: 3, note: 'my shepherd' }, { id: 'm1', t: 5, type: 'hl', b: 'JHN', c: 3, v: 16, text: 'stale edit' }, { id: '../bad', t: 1, b: 'JHN' }, { id: 'm3', t: 1, b: 'nope' }]));
|
||||||
assert.deepEqual(r.map(m => m.id).sort(), ['m1', 'm2'], 'two devices add without losing each other'); assert.equal(r.find(m => m.id === 'm1').text, 'For God so loved the world', 'an older edit does not win'); assert.equal(r.find(m => m.id === 'm2').ve, 3);
|
assert.deepEqual(r.map(m => m.id).sort(), ['m1', 'm2'], 'two devices add without losing each other'); assert.equal(r.find(m => m.id === 'm1').text, 'For God so loved the world', 'an older edit does not win'); assert.equal(r.find(m => m.id === 'm2').ve, 3);
|
||||||
r = await j(await post('/marks', [{ id: 'm1', t: 20, b: 'JHN', c: 3, v: 16, del: true }])); assert.deepEqual(r.map(m => m.id), ['m2'], 'deleted marks stay deleted');
|
r = await j(await post('/marks', [{ id: 'm1', t: 20, b: 'JHN', c: 3, v: 16, del: true }])); assert.deepEqual(r.map(m => m.id), ['m2'], 'deleted marks stay deleted');
|
||||||
assert.deepEqual(await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } })), { pos: null, marks: [] }, "each person's place and marks are their own");
|
assert.deepEqual(await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } })), { pos: null, marks: [], plan: null }, "each person's place and marks are their own");
|
||||||
assert.equal((await fetch(base + '/nope')).status, 404);
|
assert.equal((await fetch(base + '/nope')).status, 404);
|
||||||
|
// devotion: a reading plan with check-ins, and the shared prayer list
|
||||||
|
r = await j(await post('/plan', { start: '2026-01-01' })); assert.equal(r.plan.start, '2026-01-01'); assert.equal((await post('/plan', { start: 'nope' })).status, 400);
|
||||||
|
r = await j(await post('/plan/check', { day: 3, on: true })); await post('/plan/check', { day: 1, on: true }); r = await j(await post('/plan/check', { day: 3, on: true })); assert.deepEqual(r.plan.done, [1, 3], 'check-ins are kept in order, once each');
|
||||||
|
assert.equal((await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } }))).plan, null, 'each person has their own plan');
|
||||||
|
r = await j(await post('/prayers', { text: 'For Grandma' })); assert.equal(r.length, 1); const pid = r[0].id; assert.equal((await post('/prayers', { text: ' ' })).status, 400);
|
||||||
|
assert.equal((await j(await fetch(base + '/prayers', { headers: { 'x-user': 'b' } }))).length, 1, 'the whole family sees the list');
|
||||||
|
r = await j(await post('/prayers/answer', { id: pid, on: true }, { 'x-user': 'b' })); assert.equal(r[0].answered, true);
|
||||||
|
assert.equal((await post('/prayers/delete', { id: pid }, { 'x-user': 'b' })).status, 403, 'only the author or an admin removes a request'); assert.equal((await j(await post('/prayers/delete', { id: pid }))).length, 0);
|
||||||
|
// every verse of the day exists in the text
|
||||||
|
{ const code = fs.readFileSync(path.join(__dirname, '..', 'public', 'devotion-view.js'), 'utf8'), list = JSON.parse(/const DV_VERSES = (\[.*?\]);/.exec(code)[1].replace(/'/g, '"')), dd = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'bible', 'bsb.json'), 'utf8'));
|
||||||
|
for (const ref of list) { const m = /^(\w{3}) (\d+):(\d+)(?:-(\d+))?$/.exec(ref), bk = dd.books.find(b => b.id === m[1]); assert.ok(bk && bk.chapters[+m[2] - 1] && bk.chapters[+m[2] - 1].some(x => x[0] === 'v' && x[1] === +m[3]), ref + ' is in the Bible text'); } }
|
||||||
// the bundled text
|
// the bundled text
|
||||||
const d = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'bible', 'bsb.json'), 'utf8')); assert.equal(d.books.length, 66); assert.equal(d.books.reduce((n, b) => n + b.chapters.length, 0), 1189);
|
const d = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'bible', 'bsb.json'), 'utf8')); assert.equal(d.books.length, 66); assert.equal(d.books.reduce((n, b) => n + b.chapters.length, 0), 1189);
|
||||||
const verses = d.books.flatMap(b => b.chapters.flat()).filter(x => x[0] === 'v').length; assert.equal(verses, 31086, 'every verse is there');
|
const verses = d.books.flatMap(b => b.chapters.flat()).filter(x => x[0] === 'v').length; assert.equal(verses, 31086, 'every verse is there');
|
||||||
|
|||||||
Reference in new issue
Block a user