Build and Push Docker Image / build-and-push (push) Successful in 5m13s
- Earlier versions of a study: the server keeps compressed copies (about one per ten minutes of editing, the latest 30); Export shows what differs and puts one back (the replaced copy is kept) - Notes on highlights and bookmarks: synced, merged without loss, in exports and backups - Reader verse panel lists your studies and chunks that cover the verse, and opens them - Prayer journal: requests, who for, linked verses, days prayed, answers; synced, in backups and the account export; daily prayer reminder through ntfy - TODO.md lists the ideas not built yet Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
61 lines
3.1 KiB
JavaScript
61 lines
3.1 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(),
|
|
};
|
|
}
|
|
|
|
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);
|