From 207bfcb919108542789a8d0a9f043d5f1380a28e Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:45:32 -0400 Subject: [PATCH] Version history, notes on highlights and bookmarks, "you've studied this", prayer journal - 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 --- TODO.md | 30 ++++ server/accountExport.js | 2 + server/db.js | 122 ++++++++++++- server/index.js | 59 +++++- server/prayers.js | 60 +++++++ server/prayers.test.js | 39 ++++ server/readerMarks.js | 12 +- server/readerMarks.test.js | 20 ++- server/reminders.js | 34 +++- server/reminders.test.js | 12 ++ server/versions.js | 32 ++++ server/versions.test.js | 25 +++ src/App.jsx | 45 ++++- src/components/VersionHistory.jsx | 67 +++++++ src/components/VersionHistory.test.jsx | 59 ++++++ src/components/reader/MarkNote.jsx | 33 ++++ src/components/reader/MarkNote.test.jsx | 41 +++++ src/components/reader/MarksSheet.jsx | 42 +++-- src/components/reader/VerseSheet.jsx | 42 ++++- .../reader/VerseSheetStudies.test.jsx | 41 +++++ src/components/settings/DataSection.jsx | 10 +- src/components/study/PageShell.jsx | 5 +- src/hooks/useBibleReader.js | 38 +++- src/hooks/usePrayers.js | 74 ++++++++ src/hooks/usePrayers.test.js | 57 ++++++ src/hooks/useProjectLibrary.js | 9 + src/hooks/useReaderMarksSync.js | 20 ++- src/pages/ExportPage.jsx | 5 +- src/pages/PrayerPage.jsx | 168 ++++++++++++++++++ src/pages/PrayerPage.test.jsx | 80 +++++++++ src/pages/ReaderPage.jsx | 12 +- src/pages/SettingsPage.jsx | 14 ++ src/syncService.js | 11 ++ src/utils/backup.js | 10 +- src/utils/backup.test.js | 14 +- src/utils/marksExport.js | 24 ++- src/utils/marksExport.test.js | 14 ++ src/utils/prayers.js | 84 +++++++++ src/utils/prayers.test.js | 70 ++++++++ src/utils/projectDiff.js | 47 +++++ src/utils/projectDiff.test.js | 29 +++ src/utils/readerMarks.js | 22 ++- src/utils/readerMarks.test.js | 16 ++ src/utils/studyVerseIndex.js | 51 ++++++ src/utils/studyVerseIndex.test.js | 43 +++++ 45 files changed, 1669 insertions(+), 75 deletions(-) create mode 100644 TODO.md create mode 100644 server/prayers.js create mode 100644 server/prayers.test.js create mode 100644 server/versions.js create mode 100644 server/versions.test.js create mode 100644 src/components/VersionHistory.jsx create mode 100644 src/components/VersionHistory.test.jsx create mode 100644 src/components/reader/MarkNote.jsx create mode 100644 src/components/reader/MarkNote.test.jsx create mode 100644 src/components/reader/VerseSheetStudies.test.jsx create mode 100644 src/hooks/usePrayers.js create mode 100644 src/hooks/usePrayers.test.js create mode 100644 src/pages/PrayerPage.jsx create mode 100644 src/pages/PrayerPage.test.jsx create mode 100644 src/utils/prayers.js create mode 100644 src/utils/prayers.test.js create mode 100644 src/utils/projectDiff.js create mode 100644 src/utils/projectDiff.test.js create mode 100644 src/utils/studyVerseIndex.js create mode 100644 src/utils/studyVerseIndex.test.js diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..8e0624b --- /dev/null +++ b/TODO.md @@ -0,0 +1,30 @@ +# To do + +Ideas for the app that haven't been built yet, roughly in the order I'd do them. Tick one off when it ships. + +## Running it and keeping it safe +- [ ] **Email (SMTP).** A mail setting for password resets and invite links, so they don't depend on the ntfy topic. Also lets a + co-teacher get their invite by email. Needs a `SMTP_URL`-style setting and a fallback to today's behaviour. +- [ ] **Passkeys and a device list.** Sign in with Face ID or a fingerprint (WebAuthn); show where you're signed in with + "sign out other devices"; optionally a login alert through ntfy. +- [ ] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a + "restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`). +- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in, + creating a study and syncing; so far everything is tested in simulation. +- [ ] **Split `server/index.js`** (now about 1,100 lines) into one file per area (auth, projects, sharing, reader data, admin). + +## Teaching together +- [ ] **Comments between co-teachers.** Notes on a chunk addressed to the other person, instead of editing each other's text. +- [ ] **Live updates and "who's here".** A co-teacher's changes appear within seconds, with a marker like "Sam is on chunk 3" + (server-sent events or short polling). Today changes arrive when the tab regains focus or every couple of minutes. +- [ ] **Ask Claude inside the app.** Draft discussion questions, tighten an outline point, summarise notes, using the owner's own + API key kept on the server. The Claude prompts today are copy-and-paste. + +## Reading +- [ ] **Read-aloud with highlighting.** The device's voice reads the chapter and the verse being read is highlighted; useful for + the BSB and NET, which have no audio. +- [ ] **Cross-reference map.** A picture of the links between chapters, from IQ Bible's reference graph (needs the IQ Bible key). + +## Decided against, for now +- Scripture memory (verse practice with spaced review). +- Extra-biblical books (Enoch, Maccabees) and non-standard canons. diff --git a/server/accountExport.js b/server/accountExport.js index 1f22ffa..c082e61 100644 --- a/server/accountExport.js +++ b/server/accountExport.js @@ -17,11 +17,13 @@ export function buildAccountExport(user, store, now = new Date()) { projects: store.projects(), reader: { bookmarks: marks.bookmarks ?? {}, + bookmarkNotes: marks.bookmarkNotes ?? {}, highlights: marks.highlights ?? [], colorLabels: prefs.colorLabels?.v ?? {}, ink: store.ink() ?? {}, }, readingHistory: store.history?.() ?? { chapters: {}, days: [] }, + prayers: store.prayers?.() ?? [], readingPlans: { progress: plans.progress ?? [], customPlans: plans.customPlans ?? [], diff --git a/server/db.js b/server/db.js index f1c60b9..5dcfd9a 100644 --- a/server/db.js +++ b/server/db.js @@ -1,5 +1,6 @@ import Database from 'better-sqlite3'; import { mkdirSync } from 'fs'; +import { MAX_VERSIONS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; @@ -90,6 +91,15 @@ export function initDb() { `); } + // Optional daily prayer reminder, sent to the same ntfy topic. + if (!userCols.some((c) => c.name === 'prayer_enabled')) { + db.exec(` + ALTER TABLE users ADD COLUMN prayer_enabled INTEGER NOT NULL DEFAULT 0; + ALTER TABLE users ADD COLUMN prayer_time TEXT; + ALTER TABLE users ADD COLUMN last_prayer_date TEXT; + `); + } + // Reminders fire at the user's own local time, so remember their timezone. if (!userCols.some((c) => c.name === 'reminder_tz')) { db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT'); @@ -125,6 +135,29 @@ export function initDb() { ); `); + // The prayer journal: one row per account (the whole list as JSON; see server/prayers.js). + db.exec(` + CREATE TABLE IF NOT EXISTS prayers ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + data TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `); + + // Earlier copies of each study (compressed), for "Restore an earlier version". + db.exec(` + CREATE TABLE IF NOT EXISTS project_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL, + state_at INTEGER NOT NULL, + title TEXT, + chunks INTEGER NOT NULL DEFAULT 0, + words INTEGER NOT NULL DEFAULT 0, + data BLOB NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_versions_project ON project_versions(project_id, state_at); + `); + // Co-teachers: other accounts allowed to edit a study, and the single-use links that let them join. db.exec(` CREATE TABLE IF NOT EXISTS collaborators ( @@ -239,6 +272,7 @@ function parseUserRow(row) { totpEnabled: !!row.totpEnabled, reminderEnabled: !!row.reminderEnabled, votdEnabled: !!row.votdEnabled, + prayerEnabled: !!row.prayerEnabled, backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [], }; } @@ -249,7 +283,8 @@ const USER_SELECT = ` podcast_name AS podcastName, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime, reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz, - votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate + votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate, + prayer_enabled AS prayerEnabled, prayer_time AS prayerTime FROM users `; @@ -297,6 +332,36 @@ export function setVotdSettings(userId, { votdEnabled, votdTime }) { db.prepare('UPDATE users SET votd_enabled = ?, votd_time = ? WHERE id = ?').run(votdEnabled ? 1 : 0, votdTime || null, userId); } +/** Turns the daily prayer reminder on or off, and says when to send it. */ +export function setPrayerReminder(userId, { prayerEnabled, prayerTime }) { + db.prepare('UPDATE users SET prayer_enabled = ?, prayer_time = ? WHERE id = ?').run(prayerEnabled ? 1 : 0, prayerTime || null, userId); +} + +export function getUsersWithPrayerReminder() { + return db.prepare(` + SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, prayer_time AS prayerTime, + last_prayer_date AS lastPrayerDate, reminder_tz AS reminderTz + FROM users + WHERE prayer_enabled = 1 AND ntfy_topic IS NOT NULL AND prayer_time IS NOT NULL + `).all(); +} + +export function setLastPrayerDate(userId, dateString) { + db.prepare('UPDATE users SET last_prayer_date = ? WHERE id = ?').run(dateString, userId); +} + +export function getPrayers(userId) { + const row = db.prepare('SELECT data FROM prayers WHERE user_id = ?').get(userId); + try { return row ? JSON.parse(row.data) : []; } catch { return []; } +} + +export function setPrayers(userId, list) { + db.prepare(` + INSERT INTO prayers (user_id, data, updated_at) VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at + `).run(userId, JSON.stringify(list), Date.now()); +} + /** Every account with the verse of the day turned on and a topic to send it to. */ export function getUsersWithVotdEnabled() { return db.prepare(` @@ -421,19 +486,19 @@ export function setUserPrefs(userId, prefs) { /** The user's bookmarks + highlights: { bookmarks, highlights, updatedAt } (updatedAt 0 = never saved). */ export function getReaderMarks(userId) { const row = db.prepare('SELECT data, updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId); - if (!row) return { bookmarks: {}, highlights: [], updatedAt: 0 }; + if (!row) return { bookmarks: {}, highlights: [], bookmarkNotes: {}, updatedAt: 0 }; const data = JSON.parse(row.data); - return { bookmarks: data.bookmarks ?? {}, highlights: data.highlights ?? [], updatedAt: row.updatedAt }; + return { bookmarks: data.bookmarks ?? {}, highlights: data.highlights ?? [], bookmarkNotes: data.bookmarkNotes ?? {}, updatedAt: row.updatedAt }; } /** Replaces the user's bookmarks + highlights; returns the new updatedAt (always moves forward). */ -export function setReaderMarks(userId, { bookmarks, highlights }) { +export function setReaderMarks(userId, { bookmarks, highlights, bookmarkNotes = {} }) { const prev = db.prepare('SELECT updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId); const updatedAt = Math.max(Date.now(), (prev?.updatedAt ?? 0) + 1); db.prepare(` INSERT INTO reader_marks (user_id, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at - `).run(userId, JSON.stringify({ bookmarks, highlights }), updatedAt); + `).run(userId, JSON.stringify({ bookmarks, highlights, bookmarkNotes }), updatedAt); return updatedAt; } @@ -553,7 +618,8 @@ export function upsertProject(project, userId) { } // The server's clock decides the version, so a device with a wrong clock can't hide or fake changes. - const prev = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(project.id); + const prev = db.prepare('SELECT last_edited AS lastEdited, data FROM projects WHERE id = ?').get(project.id); + if (prev) keepVersion(project.id, prev); const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1); const chapterSummary = buildSummary(project); const updated = { ...project, lastEdited }; @@ -571,6 +637,48 @@ export function upsertProject(project, userId) { return { id: project.id, title: project.title, lastEdited, chapterSummary }; } +// --------------------------------------------------------------------------- +// Earlier versions +// --------------------------------------------------------------------------- + +/** Keeps the copy that is about to be replaced (when it's been long enough since the last kept one), then trims the oldest. */ +function keepVersion(projectId, prev, force = false) { + const last = db.prepare('SELECT MAX(state_at) AS at FROM project_versions WHERE project_id = ?').get(projectId)?.at; + if (!shouldSnapshot({ prevEditedAt: prev.lastEdited, lastKeptAt: last ?? undefined, force })) return; + let project; + try { project = JSON.parse(prev.data); } catch { return; } + if (db.prepare('SELECT 1 FROM project_versions WHERE project_id = ? AND state_at = ?').get(projectId, prev.lastEdited)) return; + const { chunks, words } = summarizeProject(project); + db.prepare('INSERT INTO project_versions (project_id, state_at, title, chunks, words, data) VALUES (?, ?, ?, ?, ?, ?)') + .run(projectId, prev.lastEdited, project.title ?? '', chunks, words, pack(prev.data)); + db.prepare(`DELETE FROM project_versions WHERE project_id = ? AND id NOT IN (SELECT id FROM project_versions WHERE project_id = ? ORDER BY state_at DESC LIMIT ?)`).run(projectId, projectId, MAX_VERSIONS); +} + +export function listVersions(projectId) { + return db.prepare('SELECT id, state_at AS savedAt, title, chunks, words FROM project_versions WHERE project_id = ? ORDER BY state_at DESC').all(projectId); +} + +export function getVersion(projectId, versionId) { + const row = db.prepare('SELECT data FROM project_versions WHERE project_id = ? AND id = ?').get(projectId, versionId); + if (!row) return null; + try { return unpack(row.data); } catch { return null; } +} + +/** Puts an earlier version back as the current one. The copy it replaces is kept first, so a restore can be undone too. */ +export function restoreVersion(projectId, versionId, userId) { + const old = getVersion(projectId, versionId); + if (!old || !canEditProject(projectId, userId)) return null; + return db.transaction(() => { + const prev = db.prepare('SELECT last_edited AS lastEdited, data FROM projects WHERE id = ?').get(projectId); + if (prev) keepVersion(projectId, prev, true); + const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1); + const restored = { ...old, id: projectId, lastEdited }; + db.prepare('UPDATE projects SET title = ?, last_edited = ?, chapter_summary = ?, data = ? WHERE id = ?') + .run(restored.title, lastEdited, buildSummary(restored), JSON.stringify(restored), projectId); + return restored; + })(); +} + /** * Delete a project by id, scoped to userId. No-op if not found/not owned. */ @@ -583,6 +691,7 @@ export function deleteProject(id, userId) { } db.prepare('DELETE FROM collaborators WHERE project_id = ?').run(id); db.prepare('DELETE FROM edit_invites WHERE project_id = ?').run(id); + db.prepare('DELETE FROM project_versions WHERE project_id = ?').run(id); })(); } @@ -760,6 +869,7 @@ export function adminGetProject(id) { /** Deletes any project by id, regardless of owner. */ export function adminDeleteProject(id) { + db.prepare('DELETE FROM project_versions WHERE project_id = ?').run(id); db.prepare('DELETE FROM projects WHERE id = ?').run(id); } diff --git a/server/index.js b/server/index.js index 9014aab..e484093 100644 --- a/server/index.js +++ b/server/index.js @@ -16,8 +16,9 @@ import { setUserPassword, destroyAllSessionsForUser, getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs, getReadingProgress, setReadingProgress, - setReminderSettings, setVotdSettings, + setReminderSettings, setVotdSettings, setPrayerReminder, getPrayers, setPrayers, getReaderHistory, setReaderHistory, + canEditProject, listVersions, getVersion, restoreVersion, isProjectOwner, createEditInvite, listCollaborators, countOpenEditInvites, removeCollaborator, acceptEditInvite, getSetting, setSetting, createInvite, listInvites, deleteInvite, findOpenInvite, useInvite, setPasswordReset, getPasswordReset, bumpPasswordResetAttempts, clearPasswordReset, @@ -29,6 +30,7 @@ import { generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword, generateResetCode, normalizeResetCode, } from './auth.js'; +import { mergePrayers } from './prayers.js'; import { votdAvailable } from './votd.js'; import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js'; import { registerBibleRoutes } from './bibleApi.js'; @@ -51,6 +53,7 @@ function userPayload(user) { reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null, reminderTime: user.reminderTime ?? null, votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(), + prayerEnabled: !!user.prayerEnabled, prayerTime: user.prayerTime ?? null, }; } @@ -395,6 +398,13 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(votdTime)) return res.status(400).json({ error: 'The verse of the day time must be in HH:MM format.' }); } setVotdSettings(req.session.userId, { votdEnabled, votdTime: votdTime || null }); + const prayerEnabled = !!req.body?.prayerEnabled; + const prayerTime = String(req.body?.prayerTime ?? '').trim(); + if (prayerEnabled) { + if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the prayer reminder.' }); + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(prayerTime)) return res.status(400).json({ error: 'The prayer reminder time must be in HH:MM format.' }); + } + setPrayerReminder(req.session.userId, { prayerEnabled, prayerTime: prayerTime || null }); setReminderSettings(req.session.userId, { ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, @@ -404,7 +414,7 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => { }); res.json({ reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null, - votdEnabled, votdTime: votdTime || null, + votdEnabled, votdTime: votdTime || null, prayerEnabled, prayerTime: prayerTime || null, }); } catch (err) { console.error('PATCH /api/auth/reminder-settings error:', err); @@ -519,6 +529,7 @@ app.get('/api/account/export', requireAuth, (req, res) => { ink: () => getAllReaderInk(userId), plans: () => getReadingProgress(userId), history: () => getReaderHistory(userId), + prayers: () => getPrayers(userId).filter((p) => !p.deleted), }); res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`); res.json(backup); @@ -587,6 +598,32 @@ app.put('/api/projects/:id', requireAuth, (req, res) => { } }); +// --------------------------------------------------------------------------- +// Earlier versions of a study: list, look at one, and put one back (the copy it replaces is kept too) +// --------------------------------------------------------------------------- +app.get('/api/projects/:id/versions', requireAuth, (req, res) => { + if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); + res.json({ versions: listVersions(req.params.id) }); +}); + +app.get('/api/projects/:id/versions/:versionId', requireAuth, (req, res) => { + if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); + const version = getVersion(req.params.id, Number(req.params.versionId)); + if (!version) return res.status(404).json({ error: 'That version is gone.' }); + res.json(version); +}); + +app.post('/api/projects/:id/versions/:versionId/restore', requireAuth, (req, res) => { + try { + const restored = restoreVersion(req.params.id, Number(req.params.versionId), req.session.userId); + if (!restored) return res.status(404).json({ error: 'That version is gone.' }); + res.json(restored); + } catch (err) { + console.error('POST restore error:', err); + res.status(500).json({ error: 'Failed to restore that version.' }); + } +}); + // --------------------------------------------------------------------------- // DELETE /api/projects/:id — remove a project owned by the current user // --------------------------------------------------------------------------- @@ -662,6 +699,24 @@ app.get('/api/reader/marks', requireAuth, (req, res) => { } }); +// GET /api/prayers — the prayer journal; PUT merges this device's copy in (newer prayer wins) and returns the whole +app.get('/api/prayers', requireAuth, (req, res) => { + try { res.json({ prayers: mergePrayers(getPrayers(req.session.userId), []) }); } catch (err) { + console.error('GET /api/prayers error:', err); + res.status(500).json({ error: 'Failed to load your prayers.' }); + } +}); +app.put('/api/prayers', requireAuth, (req, res) => { + try { + const merged = mergePrayers(getPrayers(req.session.userId), req.body?.prayers); + setPrayers(req.session.userId, merged); + res.json({ prayers: merged }); + } catch (err) { + console.error('PUT /api/prayers error:', err); + res.status(500).json({ error: 'Failed to save your prayers.' }); + } +}); + // GET /api/reader/history — what has been read, and PUT merges this device's copy in and returns the whole app.get('/api/reader/history', requireAuth, (req, res) => { try { res.json(mergeHistory(getReaderHistory(req.session.userId), {})); } catch (err) { diff --git a/server/prayers.js b/server/prayers.js new file mode 100644 index 0000000..233882a --- /dev/null +++ b/server/prayers.js @@ -0,0 +1,60 @@ +// 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); diff --git a/server/prayers.test.js b/server/prayers.test.js new file mode 100644 index 0000000..fe2d810 --- /dev/null +++ b/server/prayers.test.js @@ -0,0 +1,39 @@ +// @vitest-environment node +import { test, expect } from 'vitest'; +import { cleanPrayer, cleanPrayers, mergePrayers, openPrayers } from './prayers.js'; + +const NOW = 1_800_000_000_000; +const p = (id, extra = {}) => ({ id, text: `Pray ${id}`, forWhom: '', verses: [], prayedOn: [], createdAt: NOW - 5000, updatedAt: NOW - 1000, answeredAt: null, answerNote: '', ...extra }); + +test('only well-formed prayers are kept, rebuilt field by field', () => { + const out = cleanPrayer({ id: 'a-1', text: ' For my family ', forWhom: 'Home', evil: '