Files
study-bible-project/server/prayers.js
T
nmemmertandClaude Sonnet 5.5 1cf157be64
Build and Push Docker Image / build-and-push (push) Successful in 5m14s
Prayer journal: pinning, waiting/partial status, import, dashboard card
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:27:04 -04:00

64 lines
3.2 KiB
JavaScript

// The prayer journal, kept per account. Each prayer carries its own `updatedAt`, so two devices that each add or
// change prayers merge cleanly: the newer version of each prayer wins, the days prayed are combined, and a
// deleted prayer stays as a small marker for a while so another device can't bring it back.
const ID = /^[\w-]{1,64}$/;
const DAY = /^\d{4}-\d{2}-\d{2}$/;
const BOOK = /^[1-3A-Z]{3}$/;
const MAX_PRAYERS = 2000;
const MAX_DAYS = 400;
const KEEP_DELETED_MS = 90 * 86_400_000;
const text = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
const int = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
const time = (v, fallback) => (Number.isFinite(v) && v > 0 ? v : fallback);
// One prayer from the browser, rebuilt field by field; null when it isn't one.
export function cleanPrayer(raw, now = Date.now()) {
if (!raw || typeof raw !== 'object' || !ID.test(String(raw.id ?? ''))) return null;
const updatedAt = Math.min(time(raw.updatedAt, 0), now + 86_400_000);
if (raw.deleted === true) return { id: raw.id, deleted: true, updatedAt };
const body = text(raw.text, 2000).trim();
if (!body) return null;
const verses = (Array.isArray(raw.verses) ? raw.verses : []).slice(0, 8)
.filter((v) => v && BOOK.test(String(v.book)) && int(v.chapter, 1, 200) && (v.verse == null || int(v.verse, 1, 200)))
.map((v) => ({ book: v.book, chapter: v.chapter, verse: v.verse ?? null, label: text(v.label, 60) }));
const prayedOn = [...new Set((Array.isArray(raw.prayedOn) ? raw.prayedOn : []).filter((d) => typeof d === 'string' && DAY.test(d)))].sort().slice(-MAX_DAYS);
return {
id: raw.id, text: body, forWhom: text(raw.forWhom, 80).trim(), verses, prayedOn,
createdAt: time(raw.createdAt, updatedAt), updatedAt,
answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null,
answerNote: text(raw.answerNote, 1000).trim(),
status: raw.status === 'waiting' || raw.status === 'partial' ? raw.status : '',
pinned: raw.pinned === true,
rank: int(raw.rank, 0, 100_000) ? raw.rank : null,
};
}
export function cleanPrayers(list, now = Date.now()) {
const seen = new Set();
const out = [];
for (const raw of (Array.isArray(list) ? list : []).slice(0, MAX_PRAYERS)) {
const p = cleanPrayer(raw, now);
if (!p || seen.has(p.id)) continue;
seen.add(p.id);
out.push(p);
}
return out;
}
// Two copies of the journal -> one. The newer version of each prayer wins; the days prayed are the union of both.
export function mergePrayers(a, b, now = Date.now()) {
const byId = new Map();
for (const p of [...cleanPrayers(a, now), ...cleanPrayers(b, now)]) {
const have = byId.get(p.id);
if (!have) { byId.set(p.id, p); continue; }
const [newer, older] = p.updatedAt >= have.updatedAt ? [p, have] : [have, p];
byId.set(p.id, newer.deleted ? newer : { ...newer, prayedOn: [...new Set([...(newer.prayedOn ?? []), ...(older.deleted ? [] : older.prayedOn ?? [])])].sort().slice(-MAX_DAYS) });
}
return [...byId.values()]
.filter((p) => !(p.deleted && now - p.updatedAt > KEEP_DELETED_MS)) // old markers are forgotten
.sort((x, y) => (y.updatedAt ?? 0) - (x.updatedAt ?? 0));
}
export const openPrayers = (list) => list.filter((p) => !p.deleted && !p.answeredAt);