Version history, notes on highlights and bookmarks, "you've studied this", prayer journal
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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:45:32 -04:00
1 parent 54cb1c217d
commit 207bfcb919
45 files changed
+1669 -75

No files matched your search

+30
View File
@@ -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.
+2
View File
@@ -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 ?? [],
+116 -6
View File
@@ -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);
}
+57 -2
View File
@@ -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) {
+60
View File
@@ -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);
+39
View File
@@ -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: '<script>', verses: [{ book: 'PHP', chapter: 4, verse: 6, label: 'Philippians 4:6' }, { book: 'xx', chapter: 1 }, { book: 'JHN', chapter: 99999 }], prayedOn: ['2026-10-01', 'nope', '2026-10-01'], updatedAt: NOW - 1 }, NOW);
expect(out).toMatchObject({ id: 'a-1', text: 'For my family', forWhom: 'Home', prayedOn: ['2026-10-01'], answeredAt: null });
expect(out.verses).toEqual([{ book: 'PHP', chapter: 4, verse: 6, label: 'Philippians 4:6' }]);
expect('evil' in out).toBe(false);
expect(cleanPrayer({ id: 'bad id!', text: 'x' }, NOW)).toBeNull();
expect(cleanPrayer({ id: 'a', text: ' ' }, NOW)).toBeNull();
expect(cleanPrayer(null)).toBeNull();
expect(cleanPrayer({ id: 'g', deleted: true, updatedAt: NOW }, NOW)).toEqual({ id: 'g', deleted: true, updatedAt: NOW });
expect(cleanPrayers([p('a'), p('a'), 5, p('b')], NOW).map((x) => x.id)).toEqual(['a', 'b']);
});
test('the newer version of a prayer wins and the days prayed are combined', () => {
const mine = [p('a', { text: 'old wording', prayedOn: ['2026-10-01'], updatedAt: NOW - 5000 })];
const theirs = [p('a', { text: 'new wording', prayedOn: ['2026-10-02'], updatedAt: NOW - 1000 }), p('b')];
const merged = mergePrayers(mine, theirs, NOW);
expect(merged.find((x) => x.id === 'a')).toMatchObject({ text: 'new wording', prayedOn: ['2026-10-01', '2026-10-02'] });
expect(merged.map((x) => x.id).sort()).toEqual(['a', 'b']);
expect(mergePrayers(merged, merged, NOW)).toEqual(merged);
});
test('a deleted prayer is not brought back by an older copy, and old markers are forgotten', () => {
const gone = [{ id: 'a', deleted: true, updatedAt: NOW - 100 }];
const stale = [p('a', { updatedAt: NOW - 9000 })];
expect(mergePrayers(gone, stale, NOW)).toEqual([{ id: 'a', deleted: true, updatedAt: NOW - 100 }]);
expect(mergePrayers([{ id: 'z', deleted: true, updatedAt: NOW - 100 * 86_400_000 }], [], NOW)).toEqual([]);
expect(mergePrayers([p('a', { updatedAt: NOW })], [{ id: 'a', deleted: true, updatedAt: NOW - 5 }], NOW)[0].deleted).toBeUndefined(); // written again after the delete
});
test('open prayers are the ones not answered or deleted', () => {
expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']);
});
+11 -1
View File
@@ -5,6 +5,7 @@ const BOOKMARK_KEY = /^[1-3]?[A-Za-z]{2,3}-\d{1,3}-\d{1,3}$/; // e.g. "JHN-3-16"
const BOOK = /^[1-3A-Za-z]{3}$/;
const MAX_BOOKMARKS = 5000;
const MAX_HIGHLIGHTS = 20000;
const MAX_NOTE = 500;
const int = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
@@ -20,6 +21,14 @@ export function cleanMarks(body) {
}
}
// A short note on a bookmark: { "JHN-3-16": "my note" }
const bookmarkNotes = {};
if (body.bookmarkNotes && typeof body.bookmarkNotes === 'object' && !Array.isArray(body.bookmarkNotes)) {
for (const [key, note] of Object.entries(body.bookmarkNotes).slice(0, MAX_BOOKMARKS)) {
if (BOOKMARK_KEY.test(key) && typeof note === 'string' && note.trim()) bookmarkNotes[key] = note.slice(0, MAX_NOTE);
}
}
const highlights = [];
if (Array.isArray(body.highlights)) {
const seen = new Set();
@@ -39,8 +48,9 @@ export function cleanMarks(body) {
// Which translation the words were selected in, and whether it is a whole-verse highlight
if (typeof h.translation === 'string' && /^[A-Za-z0-9]{2,8}$/.test(h.translation)) entry.translation = h.translation;
if (h.whole === true) entry.whole = true;
if (typeof h.note === 'string' && h.note.trim()) entry.note = h.note.slice(0, MAX_NOTE);
highlights.push(entry);
}
}
return { bookmarks, highlights };
return { bookmarks, highlights, bookmarkNotes };
}
+17 -3
View File
@@ -15,7 +15,7 @@ test('a non-object body is rejected', () => {
test('good bookmarks and highlights pass through unchanged', () => {
const body = { bookmarks: { 'JHN-3-16': '#fef08a', '1SA-3-5': '#bbf7d0' }, highlights: [hl()] };
expect(cleanMarks(body)).toEqual(body);
expect(cleanMarks(body)).toEqual({ ...body, bookmarkNotes: {} });
});
test('malformed bookmarks are dropped, good ones kept', () => {
@@ -38,7 +38,7 @@ test('highlights are validated field by field and de-duplicated by id', () => {
});
test('missing sections become empty ones, and unknown fields are not stored', () => {
expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [] });
expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [], bookmarkNotes: {} });
expect(cleanMarks({ highlights: [{ ...hl(), evil: '<script>' }] }).highlights[0]).not.toHaveProperty('evil');
});
@@ -48,7 +48,7 @@ test('the database keeps one row per user and updatedAt only moves forward', asy
db.initDb();
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
expect(db.getReaderMarks('u1')).toEqual({ bookmarks: {}, highlights: [], updatedAt: 0 });
expect(db.getReaderMarks('u1')).toEqual({ bookmarks: {}, highlights: [], bookmarkNotes: {}, updatedAt: 0 });
const t1 = db.setReaderMarks('u1', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] });
const t2 = db.setReaderMarks('u1', { bookmarks: {}, highlights: [hl()] });
expect(t2).toBeGreaterThan(t1);
@@ -70,3 +70,17 @@ test('a highlight keeps the translation it was made in and whether it covers the
expect(out[1].translation).toBeUndefined();
expect(out[1].whole).toBeUndefined();
});
test('notes on highlights and bookmarks are kept, trimmed, and dropped when empty or malformed', async () => {
const { cleanMarks } = await import('./readerMarks.js');
const base = { book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 40, color: '#fef08a' };
const out = cleanMarks({
bookmarks: { 'JHN-3-16': '#fef08a' },
bookmarkNotes: { 'JHN-3-16': 'Memorise this', 'JHN-3-17': ' ', 'bad key': 'x', 'ROM-8-1': 5 },
highlights: [{ ...base, id: 'a', note: 'The heart of the gospel' }, { ...base, id: 'b', note: ' ' }, { ...base, id: 'c', note: 'x'.repeat(900) }],
});
expect(out.bookmarkNotes).toEqual({ 'JHN-3-16': 'Memorise this' });
expect(out.highlights[0].note).toBe('The heart of the gospel');
expect(out.highlights[1].note).toBeUndefined();
expect(out.highlights[2].note).toHaveLength(500);
});
+33 -1
View File
@@ -4,7 +4,8 @@
// No API keys or push-subscription plumbing needed.
import { lookup } from 'dns/promises';
import { isIP } from 'net';
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate } from './db.js';
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate, getUsersWithPrayerReminder, setLastPrayerDate, getPrayers } from './db.js';
import { openPrayers } from './prayers.js';
import { fetchVerseOfDay, votdMessage } from './votd.js';
// A reminder still goes out if the server was briefly down (or restarting) at the
@@ -142,10 +143,41 @@ export async function checkVotd(now = new Date()) {
}
}
// The message for the daily prayer reminder: how many requests are open, and the first few.
export function prayerMessage(open) {
const lines = open.slice(0, 3).map((p) => `• ${p.forWhom ? `${p.forWhom}: ` : ''}${p.text.replace(/\s+/g, ' ').slice(0, 80)}${p.text.length > 80 ? '…' : ''}`);
const more = open.length - lines.length;
return {
title: open.length === 1 ? 'Time to pray: 1 request' : `Time to pray: ${open.length} requests`,
body: `${lines.join('\n')}${more > 0 ? `\n…and ${more} more` : ''}`,
tags: 'pray',
};
}
// Sends each person who asked for it a nudge to pray, with what they are praying about. Nothing is sent when
// the journal has no open requests.
export async function checkPrayerReminders(now = new Date()) {
for (const user of getUsersWithPrayerReminder().filter((u) => isDue({ reminderTime: u.prayerTime, reminderTz: u.reminderTz, lastReminderDate: u.lastPrayerDate }, now))) {
const open = openPrayers(getPrayers(user.id));
if (open.length === 0) continue;
const key = `prayer:${user.id}`;
if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue;
_lastAttempt.set(key, now.getTime());
try {
await sendNtfy(user, prayerMessage(open));
_lastAttempt.delete(key);
setLastPrayerDate(user.id, zonedNow(now, user.reminderTz).date);
} catch (err) {
console.error(`Prayer reminder failed for user ${user.id}:`, err.message);
}
}
}
/** Checks once a minute whether any user's reminder time has arrived. */
export function startReminderScheduler() {
setInterval(() => {
checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
checkVotd().catch((err) => console.error('Verse of the day scheduler error:', err));
checkPrayerReminders().catch((err) => console.error('Prayer reminder scheduler error:', err));
}, 60 * 1000);
}
+12
View File
@@ -99,3 +99,15 @@ test('private and loopback addresses are rejected as reminder servers', async ()
expect(readToday({ activity: ['2026-09-29'] }, '2026-09-29')).toBe(true);
expect(readToday({ activity: [] }, '2026-09-29')).toBe(false);
});
test('the prayer reminder lists the first few open requests and counts the rest', async () => {
const { prayerMessage } = await import('./reminders.js');
const open = [
{ text: 'Healing for the whole family and a peaceful recovery after the surgery next week, with no complications at all', forWhom: 'Mum' },
{ text: 'Wisdom at work', forWhom: '' }, { text: 'Rain', forWhom: 'Farm' }, { text: 'Peace', forWhom: '' }, { text: 'Joy', forWhom: '' },
];
const m = prayerMessage(open);
expect(m.title).toBe('Time to pray: 5 requests');
expect(m.body.split('\n')).toEqual([`• Mum: ${open[0].text.slice(0, 80)}…`, '• Wisdom at work', '• Farm: Rain', '…and 2 more']);
expect(prayerMessage([{ text: 'One', forWhom: '' }]).title).toBe('Time to pray: 1 request');
});
+32
View File
@@ -0,0 +1,32 @@
// Earlier versions of a study. Just before a save replaces what the server holds, that copy can be kept, so a
// bad merge or an accidental delete can be undone. To stay small, a copy is kept at most once per window of
// editing, it is compressed, and only the newest few are kept.
import { gunzipSync, gzipSync } from 'zlib';
export const MIN_GAP_MS = 10 * 60 * 1000; // one version per ten minutes of editing history
export const MAX_VERSIONS = 30;
// `prevEditedAt` is when the copy about to be replaced was saved; `lastKeptAt` is when the newest kept version was.
export function shouldSnapshot({ prevEditedAt, lastKeptAt, force = false, minGap = MIN_GAP_MS }) {
if (!Number.isFinite(prevEditedAt)) return false;
if (force) return true;
return !Number.isFinite(lastKeptAt) || prevEditedAt - lastKeptAt >= minGap;
}
export const pack = (json) => gzipSync(Buffer.from(typeof json === 'string' ? json : JSON.stringify(json)));
export const unpack = (buf) => JSON.parse(gunzipSync(buf).toString('utf8'));
const words = (s) => (String(s ?? '').trim() ? String(s).trim().split(/\s+/).length : 0);
// How big a study is: chunks and the words in its notes and scripts.
export function summarizeProject(project) {
let chunks = 0;
let noteWords = 0;
for (const chapter of project?.chapters ?? []) {
for (const chunk of chapter.chunks ?? []) {
chunks += 1;
for (const f of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) noteWords += words(chunk[f]);
}
}
return { chunks, words: noteWords };
}
+25
View File
@@ -0,0 +1,25 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { MIN_GAP_MS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js';
test('a version is kept once per window of editing, and always the first', () => {
const t = 1_800_000_000_000;
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: undefined })).toBe(true);
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: t - 60_000 })).toBe(false); // a minute after the last one
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: t - MIN_GAP_MS })).toBe(true);
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: t - 1000, force: true })).toBe(true); // before a restore
expect(shouldSnapshot({ prevEditedAt: undefined, lastKeptAt: undefined })).toBe(false); // nothing to keep
});
test('a stored copy round-trips and is smaller than the original', () => {
const study = { id: 'a', title: 'T', chapters: [{ verses: Array.from({ length: 200 }, (_, i) => ({ number: i, text: 'In the beginning God created the heavens and the earth.' })) }] };
const packed = pack(study);
expect(packed.length).toBeLessThan(JSON.stringify(study).length / 5);
expect(unpack(packed)).toEqual(study);
});
test('a study is summarised by its chunks and the words in its notes', () => {
const s = summarizeProject({ chapters: [{ chunks: [{ observation: 'one two three', application: ' four ' }, { finalScript: 'a b' }] }, { chunks: [{}] }] });
expect(s).toEqual({ chunks: 3, words: 6 });
expect(summarizeProject(null)).toEqual({ chunks: 0, words: 0 });
});
+39 -6
View File
@@ -13,12 +13,13 @@ import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useHistoryNav } from './hooks/useHistoryNav.js';
import { usePrayers } from './hooks/usePrayers.js';
import { useReadingHistory } from './hooks/useReadingHistory.js';
import { useReadingPlans } from './hooks/useReadingPlans.js';
import { useSharedView } from './hooks/useSharedView.js';
import HomePage from './pages/HomePage.jsx';
import { themeVars } from './components/reader/readerThemes.js';
import { joinStudy, loadRemoteProject, saveRemoteProject } from './syncService.js';
import { joinStudy, loadRemoteProject, restoreStudyVersion, saveRemoteProject } from './syncService.js';
import { getSyncBase, setSyncBase } from './utils/syncBase.js';
import {
bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser,
@@ -42,6 +43,7 @@ const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx'));
const ExportPage = lazy(() => import('./pages/ExportPage.jsx'));
const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx'));
const PrayerPage = lazy(() => import('./pages/PrayerPage.jsx'));
// ---------------------------------------------------------------------------
// App component
@@ -59,7 +61,7 @@ function savedStartPage() {
const AppInner = () => {
const { toast, setTheme: setToastTheme } = useToast();
const [project, setProject] = useState(null);
// 'home' | 'setup' | 'study' | 'outline' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans'
// 'home' | 'setup' | 'study' | 'outline' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans' | 'prayer'
const [currentPage, setCurrentPage] = useState(savedStartPage);
const currentPageRef = useRef(currentPage);
currentPageRef.current = currentPage;
@@ -75,6 +77,8 @@ const AppInner = () => {
const [readerPlan, setReaderPlan] = useState(null);
const readerPlanProgress = readerPlan ? readingPlans.planProgress.find((p) => p.id === readerPlan.progressId) ?? null : null;
const reader = useBibleReader(currentPage, readerPlanProgress?.translation ?? null);
const prayerJournal = usePrayers();
const [prayerDraft, setPrayerDraft] = useState(null); // a verse to start a prayer with (from the Reader)
const readingHistory = useReadingHistory({ active: currentPage === 'reader', book: reader.readerBookAbbrev, chapter: reader.readerChapter });
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation);
useEffect(() => { if (currentPage !== 'reader') setReaderPlan(null); }, [currentPage]);
@@ -104,12 +108,14 @@ const AppInner = () => {
reader.pullServerPrefs();
// What you've read, merged with this device's.
readingHistory.pullReadingHistory();
prayerJournal.pullPrayers();
},
onSignOut: () => {
reader.stopMarksSync();
reader.stopPrefsSync();
reader.stopInkSync();
readingHistory.stopReadingHistory();
prayerJournal.stopPrayers();
readingPlans.stopReadingPlansSync();
setProjectIndex(switchStorageUser(null));
goStudies();
@@ -161,7 +167,7 @@ const AppInner = () => {
useEffect(() => {
const names = {
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Export',
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans',
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal',
};
let page = names[currentPage] ?? '';
if (currentPage === 'reader') {
@@ -251,11 +257,11 @@ const AppInner = () => {
const flushPendingSave = () => {
const pending = pendingSaveRef.current;
if (!pending) return;
if (!pending) return Promise.resolve();
window.clearTimeout(saveTimerRef.current);
pendingSaveRef.current = null;
savedSignatureRef.current = { id: pending.project.id, signature: pending.signature };
commitSave(pending.project);
return commitSave(pending.project);
};
const flushRef = useRef(flushPendingSave);
flushRef.current = flushPendingSave;
@@ -305,6 +311,27 @@ const AppInner = () => {
toast('Switched to the version from your other device');
};
// Puts an earlier version of the open study back. What you just typed is saved first (so it is in the list too),
// then the server swaps the versions, and this device takes the restored one.
const restoreEarlierVersion = async (versionId) => {
if (!project) return false;
await flushPendingSave();
const result = await restoreStudyVersion(project.id, versionId);
if (!result.ok) return false;
window.clearTimeout(saveTimerRef.current);
pendingSaveRef.current = null;
const restored = result.data;
try { saveProjectToStorage(restored); } catch { /* storage full: the account has it */ }
unsyncedStudies.remove(restored.id);
setSyncBase(restored.id, restored.lastEdited);
savedSignatureRef.current = { id: restored.id, signature: signatureOf(restored) };
setSyncConflict(null);
setProject(restored);
setProjectIndex(loadProjectIndex());
toast('Earlier version put back. What you had before is still in the list.');
return true;
};
// Third way out of a conflict: combine both copies chunk by chunk, then save the result as the new version.
const mergeBothVersions = async () => {
if (!project || !syncConflict) return;
@@ -342,6 +369,9 @@ const AppInner = () => {
const openBibleReader = () => setCurrentPage('reader');
const openReadingPlans = () => setCurrentPage('reading-plans');
const openPrayer = () => setCurrentPage('prayer');
// "Pray about this verse": opens the journal with the verse filled in
const startPrayerFor = (label) => { setPrayerDraft({ label }); setCurrentPage('prayer'); };
// Jumps straight into a specific chapter and opens the reader (no plan context).
const readReadingPlanDay = (bookAbbrev, chapter) => {
@@ -477,7 +507,7 @@ const AppInner = () => {
saveStatus, setSaveStatus,
syncStatus,
retrySync,
syncConflict, keepMyVersion, useServerVersion, mergeBothVersions,
syncConflict, keepMyVersion, useServerVersion, mergeBothVersions, restoreEarlierVersion,
// Home
projectIndex,
...library,
@@ -502,6 +532,8 @@ const AppInner = () => {
// Reader
...reader,
readingHistory: readingHistory.readingHistory,
...prayerJournal,
prayerDraft, clearPrayerDraft: () => setPrayerDraft(null), startPrayerFor, openPrayer,
readerPlan, setReaderPlan,
readerPlanActive: !!readerPlanProgress,
openPlanReading,
@@ -594,6 +626,7 @@ function AppRouter() {
if (currentPage === 'admin') return <AdminPage />;
if (currentPage === 'settings') return <SettingsPage />;
if (currentPage === 'reading-plans') return <ReadingPlansPage />;
if (currentPage === 'prayer') return <PrayerPage />;
if (currentPage === 'reader') return <ReaderPage />;
if (currentPage === 'import') return <ImportPage />;
if (currentPage === 'study') return <StudyPage />;
+67
View File
@@ -0,0 +1,67 @@
// Export → Earlier versions: the copies the server kept of this study as you worked. Look at what is different
// from now, and put one back. The version you replace is kept, so putting one back can be undone too.
import { useCallback, useEffect, useState } from 'react';
import { getStudyVersion, listStudyVersions } from '../syncService.js';
import { describeDiff, diffProjects } from '../utils/projectDiff.js';
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
export default function VersionHistory({ project, onRestore }) {
const [state, setState] = useState({ status: 'loading', versions: [] });
const [open, setOpen] = useState(null); // { id, lines } the version being looked at
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
if (!project?.id) return;
const r = await listStudyVersions(project.id);
setState(r.ok ? { status: 'ready', versions: r.data.versions } : { status: 'unavailable', versions: [] });
}, [project?.id]);
useEffect(() => { load(); }, [load]);
const preview = async (v) => {
if (open?.id === v.id) { setOpen(null); return; }
setError('');
const r = await getStudyVersion(project.id, v.id);
if (!r.ok) { setError(r.error ?? 'Could not open that version.'); return; }
setOpen({ id: v.id, lines: describeDiff(diffProjects(project, r.data)) });
};
const restore = async (v) => {
if (!window.confirm(`Put back the version from ${new Date(v.savedAt).toLocaleString()}? What you have now is kept in this list, so you can undo it.`)) return;
setBusy(true); setError('');
const ok = await onRestore(v.id);
setBusy(false);
if (ok) { setOpen(null); load(); } else setError('Could not restore that version. Check your connection and try again.');
};
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Earlier versions">
<h2 className="text-sm font-medium">Earlier versions</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">As you work, your account keeps earlier copies of this study (about one for every ten minutes of editing, the latest 30). Use one to undo a bad merge or a deletion.</p>
{state.status === 'loading' && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
{state.status === 'unavailable' && <p className="text-xs text-[var(--r-sub)]">Earlier versions need your account and a connection, and the study has to have synced once.</p>}
{state.status === 'ready' && state.versions.length === 0 && <p className="text-xs text-[var(--r-sub)]">Nothing kept yet. The first earlier version appears after you’ve changed the study a couple of times.</p>}
{state.versions.length > 0 && (
<ul className="divide-y divide-[var(--r-line)] text-sm">
{state.versions.map((v) => (
<li key={v.id} className="py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<span><span className="font-medium">{new Date(v.savedAt).toLocaleString()}</span> <span className="text-xs text-[var(--r-sub)]">{v.chunks} chunk{v.chunks === 1 ? '' : 's'} · {v.words} words</span></span>
<span className="flex gap-2">
<button type="button" onClick={() => preview(v)} aria-expanded={open?.id === v.id} className={btn}>{open?.id === v.id ? 'Hide' : 'What’s different'}</button>
<button type="button" onClick={() => restore(v)} disabled={busy} className={btn}>Put this back</button>
</span>
</div>
{open?.id === v.id && (
<ul className="mt-2 list-disc space-y-0.5 rounded-lg bg-[var(--r-bg)] py-2 pl-7 pr-3 text-xs text-[var(--r-sub)]">
{open.lines.map((line) => <li key={line}>{line}</li>)}
</ul>
)}
</li>
))}
</ul>
)}
{error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{error}</p>}
</section>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
vi.mock('../syncService.js', () => ({ listStudyVersions: vi.fn(), getStudyVersion: vi.fn() }));
import VersionHistory from './VersionHistory.jsx';
import { getStudyVersion, listStudyVersions } from '../syncService.js';
const project = { id: 'p1', title: 'Romans', chapters: [{ book: 'Romans', chapter: '8', chunks: [{ id: 'a', startVerse: 1, endVerse: 4, observation: 'new words' }] }] };
const older = { id: 'p1', title: 'Romans', chapters: [{ book: 'Romans', chapter: '8', chunks: [{ id: 'a', startVerse: 1, endVerse: 4, observation: 'old words' }, { id: 'b', startVerse: 5, endVerse: 9, observation: 'lost' }] }] };
const at = new Date(2026, 9, 5, 14, 30).getTime();
beforeEach(() => {
vi.clearAllMocks();
listStudyVersions.mockResolvedValue({ ok: true, data: { versions: [{ id: 7, savedAt: at, chunks: 2, words: 12 }] } });
getStudyVersion.mockResolvedValue({ ok: true, data: older });
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
test('lists the kept versions and says what is different from now', async () => {
const user = userEvent.setup();
render(<VersionHistory project={project} onRestore={vi.fn()} />);
expect(await screen.findByText(/2 chunks · 12 words/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /what’s different/i }));
expect(await screen.findByText('Romans 8:1–4: Observe differs.')).toBeInTheDocument();
expect(screen.getByText(/Brings back 1 chunk deleted since: Romans 8:5–9/)).toBeInTheDocument();
expect(getStudyVersion).toHaveBeenCalledWith('p1', 7);
});
test('putting one back asks first, then restores and refreshes the list', async () => {
const onRestore = vi.fn(async () => true);
const user = userEvent.setup();
render(<VersionHistory project={project} onRestore={onRestore} />);
await user.click(await screen.findByRole('button', { name: /put this back/i }));
expect(window.confirm).toHaveBeenCalled();
expect(onRestore).toHaveBeenCalledWith(7);
expect(listStudyVersions).toHaveBeenCalledTimes(2); // reloaded after
});
test('declining the question restores nothing; a failed restore says so', async () => {
const onRestore = vi.fn(async () => false);
const user = userEvent.setup();
window.confirm.mockReturnValueOnce(false);
render(<VersionHistory project={project} onRestore={onRestore} />);
await user.click(await screen.findByRole('button', { name: /put this back/i }));
expect(onRestore).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: /put this back/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/Could not restore/);
});
test('with nothing kept, or no connection, it says so', async () => {
listStudyVersions.mockResolvedValueOnce({ ok: true, data: { versions: [] } });
const { unmount } = render(<VersionHistory project={project} onRestore={vi.fn()} />);
expect(await screen.findByText(/Nothing kept yet/)).toBeInTheDocument();
unmount();
listStudyVersions.mockResolvedValueOnce({ ok: false, status: 404 });
render(<VersionHistory project={project} onRestore={vi.fn()} />);
expect(await screen.findByText(/need your account and a connection/)).toBeInTheDocument();
});
+33
View File
@@ -0,0 +1,33 @@
// A short note on a highlight or a bookmark: shown under it, added or edited in place.
import { useState } from 'react';
export default function MarkNote({ note = '', onSave, label = 'note', className = '' }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const open = () => { setDraft(note); setEditing(true); };
const save = () => { onSave(draft.trim()); setEditing(false); };
if (editing) {
return (
<div className={className}>
<textarea autoFocus value={draft} maxLength={500} rows={2} aria-label={`Your ${label}`} onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); save(); } else if (e.key === 'Escape') { e.stopPropagation(); setEditing(false); } }}
className="w-full resize-y rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" placeholder="What does this mean to you?" />
<div className="mt-1 flex items-center gap-3 text-xs">
<button type="button" onClick={save} className="font-medium text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setEditing(false)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
<span className="ml-auto text-[10px] text-[var(--r-sub)]">{draft.length}/500</span>
</div>
</div>
);
}
if (!note) {
return <div className={className}><button type="button" onClick={open} className="text-xs text-[var(--r-accent)] hover:underline">+ Add {label}</button></div>;
}
return (
<div className={`${className} flex items-start gap-2`}>
<p className="min-w-0 flex-1 whitespace-pre-wrap rounded-lg bg-[var(--r-bg)] px-2.5 py-1.5 text-[13px] italic text-[var(--r-text)]">{note}</p>
<button type="button" onClick={open} aria-label={`Edit ${label}`} className="shrink-0 text-xs text-[var(--r-sub)] hover:underline">Edit</button>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
import MarkNote from './MarkNote.jsx';
test('a note can be added, and is saved trimmed', async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(<MarkNote note="" onSave={onSave} label="highlight note" />);
await user.click(screen.getByRole('button', { name: /add highlight note/i }));
await user.type(screen.getByLabelText('Your highlight note'), ' Pray this daily ');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith('Pray this daily');
});
test('an existing note is shown, and can be edited or cleared', async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(<MarkNote note="First thought" onSave={onSave} label="note" />);
expect(screen.getByText('First thought')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Edit note' }));
const box = screen.getByLabelText('Your note');
expect(box).toHaveValue('First thought');
await user.clear(box);
await user.click(screen.getByRole('button', { name: 'Save' })); // an empty note removes it
expect(onSave).toHaveBeenCalledWith('');
});
test('cancelling keeps what was there; Ctrl+Enter saves', async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(<MarkNote note="Keep me" onSave={onSave} />);
await user.click(screen.getByRole('button', { name: /edit/i }));
await user.type(screen.getByLabelText(/your/i), ' more');
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByText('Keep me')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /edit/i }));
await user.type(screen.getByLabelText(/your/i), ' more{Control>}{Enter}{/Control}');
expect(onSave).toHaveBeenCalledWith('Keep me more');
});
+25 -17
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx';
import MarkNote from './MarkNote.jsx';
import { buildMarksDocxBlob, buildMarksModel, downloadMarksFile, marksToMarkdown } from '../../utils/marksExport.js';
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
@@ -10,6 +11,7 @@ const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${
export default function MarksSheet({
open, onClose, initialTab = 'highlights', bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
bookmarkNotes = {}, onSetHighlightNote, onSetBookmarkNote,
}) {
const [tab, setTab] = useState(initialTab);
// Opening from the Highlights button shows highlights; from the Bookmarks button, bookmarks
@@ -24,7 +26,7 @@ export default function MarksSheet({
const [exportError, setExportError] = useState('');
const exportAs = async (kind) => {
setExportError('');
const model = buildMarksModel({ highlights, bookmarks, colorLabels });
const model = buildMarksModel({ highlights, bookmarks, colorLabels, bookmarkNotes });
try {
if (kind === 'md') downloadMarksFile(new Blob([marksToMarkdown(model)], { type: 'text/markdown;charset=utf-8' }), 'highlights-and-bookmarks.md');
else downloadMarksFile(await buildMarksDocxBlob(model), 'highlights-and-bookmarks.docx');
@@ -73,15 +75,18 @@ export default function MarksSheet({
) : shown.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">Nothing highlighted in that colour yet.</p>
) : shown.map((h) => (
<div key={h.id} className="flex items-start gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
</button>
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
<div key={h.id} className="rounded-lg px-1 py-1.5 hover:bg-black/5">
<div className="flex items-start gap-2">
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
</button>
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
{onSetHighlightNote && <MarkNote className="ml-5 mt-1" note={h.note} label={`note on ${h.bookName} ${h.chapter}:${h.verse}`} onSave={(text) => onSetHighlightNote(h.id, text)} />}
</div>
))}
</>
@@ -91,13 +96,16 @@ export default function MarksSheet({
bookmarks.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
) : bookmarks.map((e) => (
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse}
</button>
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
<div key={e.key} className="rounded-lg px-1 py-1.5 hover:bg-black/5">
<div className="flex items-center gap-2">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse}
</button>
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
{onSetBookmarkNote && <MarkNote className="ml-5 mt-1" note={bookmarkNotes[e.key]} label={`note on ${e.bookName} ${e.chapter}:${e.verse}`} onSave={(text) => onSetBookmarkNote(e.key, text)} />}
</div>
))
)}
+41 -1
View File
@@ -11,6 +11,7 @@ import { DiffText } from './DiffText.jsx';
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
import { isWholeVerse } from '../../utils/highlightSpans.js';
import { useIqAvailable } from '../../hooks/useIq.js';
import MarkNote from './MarkNote.jsx';
import VerseShareImage from '../VerseShareImage.jsx';
import IqLexicon from '../iq/IqLexicon.jsx';
import IqStudyAids from '../iq/IqStudyAids.jsx';
@@ -155,7 +156,7 @@ function CompareTab({ book, chapter, verse, current }) {
export default function VerseSheet({
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, request,
bookmarkColor, onToggleBookmark, onCycleColor, bookmarkNote = '', onSetBookmarkNote, onSetHighlightNote, studiesHere = [], onOpenStudy, onPray, onJump, onClose, request,
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, panelRef, resizeHandle,
}) {
const iqOn = useIqAvailable();
@@ -204,6 +205,11 @@ export default function VerseSheet({
<span className="block h-4 w-4 rounded-full border border-black/10" style={{ backgroundColor: bookmarkColor }} />
</button>
)}
{onPray && (
<button type="button" onClick={() => onPray(`${bookName} ${chapter}:${verse.number}`)} className={`${actionBtn} text-xs`} aria-label="Pray about this verse" title="Add a prayer about this verse">
Pray
</button>
)}
<button type="button" onClick={() => setImageOpen((o) => !o)} aria-pressed={imageOpen} className={`${actionBtn} text-xs`} aria-label="Share verse as image" title="Share as image">
Image
</button>
@@ -240,6 +246,40 @@ export default function VerseSheet({
<span className="basis-full text-[11px] text-[var(--r-sub)]">Or select words in the text to highlight just those.</span>
</div>
)}
{studiesHere.length > 0 && onOpenStudy && (
<div className="mb-3 rounded-xl border border-[var(--r-line)] p-3" aria-label="In your studies">
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">You’ve studied this</p>
<ul className="space-y-2">
{studiesHere.slice(0, 5).map((e) => (
<li key={`${e.projectId}/${e.chunkId}`} className="flex items-start justify-between gap-3 text-sm">
<span className="min-w-0">
<span className="font-medium">{e.title}</span> <span className="text-xs text-[var(--r-sub)]">{e.ref}</span>
{e.snippet && <span className="mt-0.5 block truncate font-serif text-[13px] text-[var(--r-sub)]">{e.snippet}</span>}
</span>
<button type="button" onClick={() => onOpenStudy(e.projectId, e.chunkId)} className="shrink-0 text-xs font-medium text-[var(--r-accent)] hover:underline">Open</button>
</li>
))}
</ul>
{studiesHere.length > 5 && <p className="mt-1.5 text-[11px] text-[var(--r-sub)]">and {studiesHere.length - 5} more</p>}
</div>
)}
{(bookmarkColor && onSetBookmarkNote) || (verseHighlights.length > 0 && onSetHighlightNote) ? (
<div className="mb-3 space-y-2 rounded-xl border border-[var(--r-line)] p-3" aria-label="Your notes on this verse">
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</p>
{bookmarkColor && onSetBookmarkNote && (
<div>
<p className="flex items-center gap-1.5 text-xs text-[var(--r-sub)]"><span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: bookmarkColor }} />Bookmark</p>
<MarkNote note={bookmarkNote} label="bookmark note" onSave={onSetBookmarkNote} className="mt-1" />
</div>
)}
{onSetHighlightNote && verseHighlights.map((h) => (
<div key={h.id}>
<p className="flex items-center gap-1.5 truncate text-xs text-[var(--r-sub)]"><span className="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} /><span className="truncate">{isWholeVerse(h, verse.text) ? 'Whole verse' : `“${h.text}”`}</span></p>
<MarkNote note={h.note} label="highlight note" onSave={(text) => onSetHighlightNote(h.id, text)} className="mt-1" />
</div>
))}
</div>
) : null}
<div role="tablist" aria-label="About this verse" className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
{TABS.map(([id, label]) => (
<button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
import VerseSheet from './VerseSheet.jsx';
vi.mock('../StudyPanel.jsx', () => ({ CommentaryTab: () => null, PeoplePlacesTab: () => null }));
const base = {
verse: { number: 1, text: 'Paul, a servant of God.' }, book: 'TIT', bookName: 'Titus', chapter: 1, translation: 'BSB',
onToggleBookmark: () => {}, onCycleColor: () => {}, onJump: () => {}, onClose: () => {}, onLoadCrossRefs: () => {},
};
const here = [{ projectId: 'p1', chunkId: 'a', title: 'Titus Notes', ref: 'Titus 1:1–4', snippet: 'Paul calls himself a servant' }];
test('the verse panel lists your studies that cover the verse, and opens one at that chunk', async () => {
const onOpenStudy = vi.fn();
render(<VerseSheet {...base} studiesHere={here} onOpenStudy={onOpenStudy} />);
expect(screen.getByText('You’ve studied this')).toBeInTheDocument();
expect(screen.getByText('Titus Notes')).toBeInTheDocument();
expect(screen.getByText('Paul calls himself a servant')).toBeInTheDocument();
await userEvent.setup().click(screen.getByRole('button', { name: 'Open' }));
expect(onOpenStudy).toHaveBeenCalledWith('p1', 'a');
});
test('nothing is shown when no study covers the verse', () => {
render(<VerseSheet {...base} studiesHere={[]} onOpenStudy={() => {}} />);
expect(screen.queryByText('You’ve studied this')).toBeNull();
});
test('your notes on the verse show up in the panel and can be added', async () => {
const onSetBookmarkNote = vi.fn();
const onSetHighlightNote = vi.fn();
const user = userEvent.setup();
render(<VerseSheet {...base} bookmarkColor="#fef08a" bookmarkNote="Memorise" onSetBookmarkNote={onSetBookmarkNote}
verseHighlights={[{ id: 'h1', color: '#bbf7d0', startOffset: 0, endOffset: 4, text: 'Paul' }]} onSetHighlightNote={onSetHighlightNote} />);
expect(screen.getByText('Memorise')).toBeInTheDocument();
expect(screen.getByText('“Paul”')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /add highlight note/i }));
await user.type(screen.getByLabelText('Your highlight note'), 'Who he is');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(onSetHighlightNote).toHaveBeenCalledWith('h1', 'Who he is');
});
+6 -4
View File
@@ -13,8 +13,8 @@ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
export default function DataSection() {
const {
authUser, readerBookmarks, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
importReaderMarks, importReadingPlans, refreshProjectIndex,
authUser, readerBookmarks, readerBookmarkNotes, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
importReaderMarks, importReadingPlans, refreshProjectIndex, prayers, importPrayers,
} = useApp();
const { toast } = useToast();
const offline = useOfflineSave();
@@ -26,8 +26,9 @@ export default function DataSection() {
const download = () => {
const backup = buildBackup({
email: authUser?.email ?? null,
reader: { bookmarks: readerBookmarks, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
reader: { bookmarks: readerBookmarks, bookmarkNotes: readerBookmarkNotes, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
readingPlans: readingPlansState,
prayers,
});
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
@@ -59,6 +60,7 @@ export default function DataSection() {
const written = restoreProjects(backup.projects);
importReaderMarks(backup.reader);
importReadingPlans(backup.readingPlans);
importPrayers?.(backup.prayers);
refreshProjectIndex();
// Studies restored here also go to the account, so they follow you to other devices
if (authUser) await Promise.all(written.map((p) => saveRemoteProject(p)));
@@ -88,7 +90,7 @@ export default function DataSection() {
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 text-sm">
<p className="font-medium">{pending.name}</p>
<p className="mt-1 text-[var(--r-sub)]">
Contains {plural(s.projects, 'study', 'studies')}, {plural(s.bookmarks, 'bookmark')}, {plural(s.highlights, 'highlight')}, {plural(s.inkPages, 'ink page')} and {plural(s.plans, 'reading plan')}.
Contains {plural(s.projects, 'study', 'studies')}, {plural(s.bookmarks, 'bookmark')}, {plural(s.highlights, 'highlight')}, {plural(s.inkPages, 'ink page')}, {plural(s.plans, 'reading plan')} and {plural(s.prayers ?? 0, 'prayer')}.
Restoring adds these to what you have — nothing is deleted, and a study on this device is only replaced if the backup’s copy is newer.
</p>
<div className="mt-3 flex gap-2">
+3 -2
View File
@@ -10,6 +10,7 @@ const NAV = [
{ id: 'studies', label: 'Studies', short: 'Studies' },
{ id: 'reader', label: 'Read Bible', short: 'Bible' },
{ id: 'plans', label: 'Reading Plans', short: 'Plans' },
{ id: 'prayer', label: 'Prayer', short: 'Prayer' },
];
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
@@ -62,8 +63,8 @@ function AccountMenu() {
}
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPalette } = useApp();
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans };
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPrayer, openPalette } = useApp();
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans, prayer: openPrayer };
return (
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<a href="#content" className="skip-link">Skip to content</a>
+34 -4
View File
@@ -130,6 +130,10 @@ export function useBibleReader(currentPage, translationOverride = null) {
const [readerBookmarks, setReaderBookmarks] = useState(() => {
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
});
// A short note on a bookmark: { "JHN-3-16": "text" }. (A note on a highlight lives on the highlight itself.)
const [readerBookmarkNotes, setReaderBookmarkNotes] = useState(() => {
try { const v = JSON.parse(localStorage.getItem('reader-bookmark-notes') || '{}'); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; } catch { return {}; }
});
const [readerBookmarksPanelOpen, setReaderBookmarksPanelOpen] = useState(false);
const [readerJumpVerse, setReaderJumpVerse] = useState(null); // verse number to scroll to + flash after a jump
const [readerCrossRefs, setReaderCrossRefs] = useState(null);
@@ -451,6 +455,29 @@ export function useBibleReader(currentPage, translationOverride = null) {
});
};
const setBookmarkNote = (verseKey, note) => {
setReaderBookmarkNotes((prev) => {
const text = String(note ?? '').slice(0, 500);
const next = { ...prev };
if (text.trim()) next[verseKey] = text; else delete next[verseKey];
try { localStorage.setItem('reader-bookmark-notes', JSON.stringify(next)); } catch {}
return next;
});
};
const setHighlightNote = (id, note) => {
setReaderTextHighlights((prev) => {
const text = String(note ?? '').slice(0, 500);
const next = prev.map((h) => {
if (h.id !== id) return h;
const { note: _old, ...rest } = h;
return text.trim() ? { ...rest, note: text } : rest;
});
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
return next;
});
};
// Puts a removed highlight back exactly as it was (used by "Undo").
const restoreTextHighlight = (highlight) => {
setReaderTextHighlights((prev) => {
@@ -495,13 +522,15 @@ export function useBibleReader(currentPage, translationOverride = null) {
// Bookmarks, highlights, colour names and ink from a backup file are added to what is here (nothing is
// removed, and where both have something the copy already here wins).
const importReaderMarks = ({ bookmarks, highlights, colorLabels, ink }) => {
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights }));
const importReaderMarks = ({ bookmarks, bookmarkNotes, highlights, colorLabels, ink }) => {
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights, bookmarkNotes }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights, bookmarkNotes: readerBookmarkNotes }));
setReaderBookmarks(merged.bookmarks);
setReaderTextHighlights(merged.highlights);
setReaderBookmarkNotes(merged.bookmarkNotes);
try {
localStorage.setItem('reader-bookmarks', JSON.stringify(merged.bookmarks));
localStorage.setItem('readerTextHighlights', JSON.stringify(merged.highlights));
localStorage.setItem('reader-bookmark-notes', JSON.stringify(merged.bookmarkNotes));
} catch {}
if (colorLabels && Object.keys(colorLabels).length > 0) {
setReaderColorLabelsState((prev) => {
@@ -540,8 +569,8 @@ export function useBibleReader(currentPage, translationOverride = null) {
// Bookmarks and highlights follow the signed-in user across devices.
const marksSync = useReaderMarksSync({
bookmarks: readerBookmarks, highlights: readerTextHighlights,
setBookmarks: setReaderBookmarks, setHighlights: setReaderTextHighlights,
bookmarks: readerBookmarks, highlights: readerTextHighlights, bookmarkNotes: readerBookmarkNotes,
setBookmarks: setReaderBookmarks, setHighlights: setReaderTextHighlights, setBookmarkNotes: setReaderBookmarkNotes,
});
// Theme, default translation, layout, commentary and reading position follow the account too.
@@ -590,6 +619,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
planTranslationPref, setPlanTranslationPref,
startPagePref, setStartPagePref,
readerBookmarks, setReaderBookmarks,
readerBookmarkNotes, setBookmarkNote, setHighlightNote,
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
+74
View File
@@ -0,0 +1,74 @@
// The prayer journal on this device, kept in step with your account. Every change is saved here at once and
// sent to the account a moment later; signing in merges the two (the newer version of each prayer wins), so a prayer
// added on your phone appears on your computer. Signing out clears this device's copy.
import { useCallback, useEffect, useRef, useState } from 'react';
import { getRemotePrayers, saveRemotePrayers } from '../syncService.js';
import { markAnswered, markDeleted, mergePrayers, newPrayer, prayersEqual, reopen, touch, togglePrayedToday } from '../utils/prayers.js';
import { localDateString } from '../utils/readingPlans.js';
const KEY = 'prayers';
const PUSH_DELAY_MS = 1500;
const load = () => { try { const v = JSON.parse(localStorage.getItem(KEY) || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } };
const save = (list) => { try { localStorage.setItem(KEY, JSON.stringify(list)); } catch { /* storage unavailable */ } };
export function usePrayers() {
const [prayers, setPrayersState] = useState(load);
const ref = useRef(prayers);
ref.current = prayers;
const enabledRef = useRef(false);
const timerRef = useRef(null);
const push = useCallback(async () => {
window.clearTimeout(timerRef.current);
if (!enabledRef.current) return;
const result = await saveRemotePrayers(ref.current);
if (result.ok) {
const merged = mergePrayers(ref.current, result.data?.prayers);
if (!prayersEqual(merged, ref.current)) { ref.current = merged; save(merged); setPrayersState(merged); }
}
}, []);
const set = useCallback((next) => {
ref.current = next;
save(next);
setPrayersState(next);
if (enabledRef.current) { window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(push, PUSH_DELAY_MS); }
}, [push]);
const replace = (id, fn) => set(ref.current.map((p) => (p.id === id ? fn(p) : p)));
const addPrayer = (fields) => { const p = newPrayer(fields); set([p, ...ref.current]); return p; };
const editPrayer = (id, patch) => replace(id, (p) => touch(p, patch));
const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString()));
const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note));
const reopenPrayer = (id) => replace(id, (p) => reopen(p));
// Deleting leaves a marker so the other devices drop it too; the returned function puts it back
const deletePrayer = (id) => {
const before = ref.current.find((p) => p.id === id);
if (!before) return () => {};
set(ref.current.map((p) => (p.id === id ? markDeleted(p) : p)));
return () => set(ref.current.map((p) => (p.id === id ? touch(before, {}) : p)));
};
// Prayers from a backup file are added; a prayer already here keeps whichever copy is newer
const importPrayers = (list) => set(mergePrayers(ref.current, list));
const pull = useCallback(async () => {
enabledRef.current = true;
const result = await getRemotePrayers();
if (!result.ok) return;
const merged = mergePrayers(ref.current, result.data?.prayers);
if (!prayersEqual(merged, ref.current)) { ref.current = merged; save(merged); setPrayersState(merged); }
if (!prayersEqual(merged, result.data?.prayers ?? [])) await push();
}, [push]);
const stop = useCallback(() => {
enabledRef.current = false;
window.clearTimeout(timerRef.current);
try { localStorage.removeItem(KEY); } catch { /* storage unavailable */ }
ref.current = [];
setPrayersState([]);
}, []);
useEffect(() => () => window.clearTimeout(timerRef.current), []);
return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop };
}
+57
View File
@@ -0,0 +1,57 @@
import { renderHook, act } from '@testing-library/react';
import { vi, test, expect, beforeEach } from 'vitest';
vi.mock('../syncService.js', () => ({ getRemotePrayers: vi.fn(), saveRemotePrayers: vi.fn() }));
import { usePrayers } from './usePrayers.js';
import { getRemotePrayers, saveRemotePrayers } from '../syncService.js';
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
getRemotePrayers.mockResolvedValue({ ok: true, data: { prayers: [] } });
saveRemotePrayers.mockImplementation(async (list) => ({ ok: true, data: { prayers: list } }));
});
test('prayers are added, changed, answered and kept on this device', () => {
const { result } = renderHook(() => usePrayers());
let id;
act(() => { id = result.current.addPrayer({ text: 'Peace at home', forWhom: 'Family' }).id; });
expect(result.current.prayers[0]).toMatchObject({ text: 'Peace at home', forWhom: 'Family' });
act(() => result.current.editPrayer(id, { text: 'Peace and joy at home' }));
act(() => result.current.togglePrayed(id));
expect(result.current.prayers[0].prayedOn).toHaveLength(1);
act(() => result.current.answerPrayer(id, 'Yes'));
expect(result.current.prayers[0]).toMatchObject({ text: 'Peace and joy at home', answerNote: 'Yes' });
expect(result.current.prayers[0].answeredAt).toBeGreaterThan(0);
act(() => result.current.reopenPrayer(id));
expect(result.current.prayers[0].answeredAt).toBeNull();
expect(JSON.parse(localStorage.getItem('prayers'))[0].text).toBe('Peace and joy at home');
});
test('a deleted prayer can be taken back', () => {
const { result } = renderHook(() => usePrayers());
let id; let undo;
act(() => { id = result.current.addPrayer({ text: 'Keep me' }).id; });
act(() => { undo = result.current.deletePrayer(id); });
expect(result.current.prayers[0].deleted).toBe(true);
act(() => undo());
expect(result.current.prayers[0]).toMatchObject({ id, text: 'Keep me' });
expect(result.current.prayers[0].deleted).toBeUndefined();
});
test('signing in merges with the account and sends back what it lacked', async () => {
localStorage.setItem('prayers', JSON.stringify([{ id: 'local', text: 'Only here', forWhom: '', verses: [], prayedOn: [], createdAt: 1, updatedAt: 5, answeredAt: null, answerNote: '' }]));
getRemotePrayers.mockResolvedValue({ ok: true, data: { prayers: [{ id: 'remote', text: 'Only there', forWhom: '', verses: [], prayedOn: [], createdAt: 1, updatedAt: 6, answeredAt: null, answerNote: '' }] } });
const { result } = renderHook(() => usePrayers());
await act(async () => { await result.current.pullPrayers(); });
expect(result.current.prayers.map((p) => p.id).sort()).toEqual(['local', 'remote']);
expect(saveRemotePrayers).toHaveBeenCalled();
});
test('signing out clears the journal from this device', async () => {
const { result } = renderHook(() => usePrayers());
act(() => { result.current.addPrayer({ text: 'Private' }); });
act(() => result.current.stopPrayers());
expect(result.current.prayers).toEqual([]);
expect(localStorage.getItem('prayers')).toBeNull();
});
+9
View File
@@ -171,6 +171,14 @@ export function useProjectLibrary({
setCurrentPage(hasSelectedChunk ? 'study' : 'setup');
};
// Opens a study on one particular chunk (from the Reader's "In your studies").
const openStudyChunk = (id, chunkId) => {
const loaded = loadProjectById(id);
if (!loaded) return;
setProject({ ...loaded, selectedChunkId: chunkId });
setCurrentPage('study');
};
const renameProjectInStorage = (id, title) => {
const loaded = loadProjectById(id);
if (!loaded) return;
@@ -276,6 +284,7 @@ export function useProjectLibrary({
staleLocalProjects,
autoRestoredCount,
resumeProject,
openStudyChunk,
renameProjectInStorage,
setProjectSeries,
deleteProject,
+11 -9
View File
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef } from 'react';
import { getRemoteMarks, saveRemoteMarks } from '../syncService.js';
import { emptyMarks, isEmptyMarks, marksEqual, mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
const KEYS = { bookmarks: 'reader-bookmarks', highlights: 'readerTextHighlights', syncedAt: 'reader-marks-synced-at', dirty: 'reader-marks-dirty' };
const KEYS = { bookmarks: 'reader-bookmarks', highlights: 'readerTextHighlights', bookmarkNotes: 'reader-bookmark-notes', syncedAt: 'reader-marks-synced-at', dirty: 'reader-marks-dirty' };
const PUSH_DELAY_MS = 1500;
const FOCUS_PULL_MIN_MS = 20_000;
@@ -14,10 +14,10 @@ const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return nu
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* storage unavailable */ } };
const lsDel = (k) => { try { localStorage.removeItem(k); } catch { /* storage unavailable */ } };
export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHighlights }) {
export function useReaderMarksSync({ bookmarks, highlights, bookmarkNotes = {}, setBookmarks, setHighlights, setBookmarkNotes = () => {} }) {
const enabledRef = useRef(false);
const latestRef = useRef({ bookmarks, highlights });
latestRef.current = { bookmarks, highlights };
const latestRef = useRef({ bookmarks, highlights, bookmarkNotes });
latestRef.current = { bookmarks, highlights, bookmarkNotes };
const timerRef = useRef(null);
const skipRef = useRef(false); // set while applying server data, so it isn't mistaken for a local edit
const firstRunRef = useRef(true);
@@ -31,13 +31,13 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
if (!enabledRef.current) return;
if (pushingRef.current) { await pushingRef.current; }
const snapshot = latestRef.current;
pushingRef.current = saveRemoteMarks({ bookmarks: snapshot.bookmarks, highlights: snapshot.highlights });
pushingRef.current = saveRemoteMarks({ bookmarks: snapshot.bookmarks, highlights: snapshot.highlights, bookmarkNotes: snapshot.bookmarkNotes });
const result = await pushingRef.current;
pushingRef.current = null;
if (!result.ok) return; // stays dirty; retried on the next change or when the tab regains focus
lsSet(KEYS.syncedAt, String(result.data.updatedAt));
// Only clean if nothing changed while the request was in flight
if (latestRef.current.bookmarks === snapshot.bookmarks && latestRef.current.highlights === snapshot.highlights) setDirty(false);
if (latestRef.current.bookmarks === snapshot.bookmarks && latestRef.current.highlights === snapshot.highlights && latestRef.current.bookmarkNotes === snapshot.bookmarkNotes) setDirty(false);
else timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}, []);
@@ -50,7 +50,7 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}
}, [bookmarks, highlights, push]);
}, [bookmarks, highlights, bookmarkNotes, push]);
// Puts `next` on screen and in localStorage without treating it as a local edit.
const apply = useCallback((next) => {
@@ -58,9 +58,11 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
skipRef.current = true;
lsSet(KEYS.bookmarks, JSON.stringify(next.bookmarks));
lsSet(KEYS.highlights, JSON.stringify(next.highlights));
lsSet(KEYS.bookmarkNotes, JSON.stringify(next.bookmarkNotes ?? {}));
setBookmarks(next.bookmarks);
setHighlights(next.highlights);
}, [setBookmarks, setHighlights]);
setBookmarkNotes(next.bookmarkNotes ?? {});
}, [setBookmarks, setHighlights, setBookmarkNotes]);
// Sign-in (and tab focus): reconcile this device with the server's copy.
const pullServerMarks = useCallback(async () => {
@@ -103,7 +105,7 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
const stopMarksSync = useCallback(() => {
enabledRef.current = false;
window.clearTimeout(timerRef.current);
[KEYS.bookmarks, KEYS.highlights, KEYS.syncedAt, KEYS.dirty].forEach(lsDel);
[KEYS.bookmarks, KEYS.highlights, KEYS.bookmarkNotes, KEYS.syncedAt, KEYS.dirty].forEach(lsDel);
apply(emptyMarks());
}, [apply]);
+4 -1
View File
@@ -3,6 +3,7 @@ import { useApp } from '../context/AppContext.js';
import { useShareLink } from '../hooks/useShareLink.js';
import HandoutExport from '../components/HandoutExport.jsx';
import SessionSchedule from '../components/SessionSchedule.jsx';
import VersionHistory from '../components/VersionHistory.jsx';
import CoTeachers from '../components/CoTeachers.jsx';
import LinkRules from '../components/LinkRules.jsx';
import StudioShell from '../components/study/StudioShell.jsx';
@@ -21,7 +22,7 @@ function Row({ title, note, button, onClick }) {
}
export default function ExportPage() {
const { project, allChunks, setCurrentPage, sharedStudies, deleteProject } = useApp();
const { project, allChunks, setCurrentPage, sharedStudies, deleteProject, restoreEarlierVersion } = useApp();
const share = useShareLink(project?.id);
const filled = allChunks.filter((c) => (c.finalScript ?? '').trim()).length;
@@ -66,6 +67,8 @@ export default function ExportPage() {
<CoTeachers projectId={project?.id} sharedBy={project && sharedStudies?.[project.id]} onLeave={() => { deleteProject(project.id); setCurrentPage('home'); }} />
<VersionHistory project={project} onRestore={restoreEarlierVersion} />
<section className={card} aria-label="Pronunciation">
<Row title="Pronunciation guide" note="Every Greek and Hebrew word in this study, with transliterations." button="Open guide" onClick={() => setCurrentPage('pronunciation')} />
</section>
+168
View File
@@ -0,0 +1,168 @@
// The prayer journal: what you are praying about (and for whom), the verses tied to each request, the days you
// prayed, and the answers when they come.
import { useEffect, useMemo, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
import { useToast } from '../components/Toasts.jsx';
import { filterPrayers, daysToAnswer, labelFor, live, parseVerses, prayersToMarkdown } from '../utils/prayers.js';
import { localDateString } from '../utils/readingPlans.js';
const field = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const small = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1 text-xs hover:bg-black/5';
const tabBtn = (on) => `rounded-full border px-3 py-1 text-xs font-medium ${on ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
function VerseChips({ verses, onOpen }) {
if (!verses?.length) return null;
return (
<span className="mt-1 flex flex-wrap gap-1.5">
{verses.map((v, i) => (
<button key={`${v.book}${v.chapter}${v.verse}${i}`} type="button" onClick={() => onOpen(v.book, v.chapter, v.verse ?? 1)}
className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-accent)] hover:bg-black/5" title="Read it in the Bible">{labelFor(v)}</button>
))}
</span>
);
}
function PrayerCard({ prayer, today, actions, onOpenVerse }) {
const [mode, setMode] = useState(null); // null | 'edit' | 'answer'
const [text, setText] = useState('');
const [note, setNote] = useState('');
const prayedToday = prayer.prayedOn.includes(today);
const days = daysToAnswer(prayer);
return (
<li className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4">
{mode === 'edit' ? (
<div>
<textarea aria-label="Edit prayer" value={text} onChange={(e) => setText(e.target.value)} rows={3} maxLength={2000} className={`${field} w-full`} />
<div className="mt-2 flex gap-3 text-xs">
<button type="button" onClick={() => { if (text.trim()) actions.edit(prayer.id, { text: text.trim() }); setMode(null); }} className="font-medium text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setMode(null)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</div>
) : (
<div>
{prayer.forWhom && <p className="text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">For {prayer.forWhom}</p>}
<p className="whitespace-pre-wrap text-[15px] leading-relaxed">{prayer.text}</p>
<VerseChips verses={prayer.verses} onOpen={onOpenVerse} />
</div>
)}
{prayer.answeredAt && (
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note">
<p className="text-xs font-medium text-[var(--r-accent)]">Answered {new Date(prayer.answeredAt).toLocaleDateString()}{days != null ? ` · after ${days} day${days === 1 ? '' : 's'}` : ''}</p>
{prayer.answerNote && <p className="mt-0.5 whitespace-pre-wrap">{prayer.answerNote}</p>}
</div>
)}
{mode === 'answer' && (
<div className="mt-3">
<textarea aria-label="How was it answered?" autoFocus value={note} onChange={(e) => setNote(e.target.value)} rows={2} maxLength={1000} placeholder="How was it answered? (optional)" className={`${field} w-full`} />
<div className="mt-2 flex gap-3 text-xs">
<button type="button" onClick={() => { actions.answer(prayer.id, note); setMode(null); }} className="font-medium text-[var(--r-accent)] hover:underline">Mark answered</button>
<button type="button" onClick={() => setMode(null)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
{!prayer.answeredAt && (
<button type="button" onClick={() => actions.toggle(prayer.id)} aria-pressed={prayedToday} className={`${small} ${prayedToday ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>
{prayedToday ? '✓ Prayed today' : 'I prayed'}
</button>
)}
{prayer.prayedOn.length > 0 && <span className="text-xs text-[var(--r-sub)]">Prayed on {prayer.prayedOn.length} day{prayer.prayedOn.length === 1 ? '' : 's'}</span>}
<span className="ml-auto flex gap-2">
{prayer.answeredAt
? <button type="button" onClick={() => actions.reopen(prayer.id)} className={small}>Pray again</button>
: <button type="button" onClick={() => { setNote(''); setMode('answer'); }} className={small}>Answered</button>}
<button type="button" onClick={() => { setText(prayer.text); setMode('edit'); }} className={small}>Edit</button>
<button type="button" onClick={() => actions.remove(prayer.id)} className={`${small} text-[var(--r-danger,#b42318)]`}>Delete</button>
</span>
</div>
</li>
);
}
export default function PrayerPage() {
const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, prayerDraft, clearPrayerDraft, jumpToReaderVerse, openBibleReader } = useApp();
const { toast } = useToast();
const [tab, setTab] = useState('open');
const [query, setQuery] = useState('');
const [form, setForm] = useState({ text: '', forWhom: '', verses: '' });
const today = localDateString();
// "Pray about this verse" from the Reader arrives with the verse filled in
useEffect(() => {
if (prayerDraft) { setForm((f) => ({ ...f, verses: prayerDraft.label })); clearPrayerDraft(); }
}, [prayerDraft, clearPrayerDraft]);
const parsed = useMemo(() => parseVerses(form.verses), [form.verses]);
const shown = filterPrayers(prayers, { tab, query });
const counts = { open: filterPrayers(prayers, { tab: 'open' }).length, answered: filterPrayers(prayers, { tab: 'answered' }).length };
const add = (e) => {
e.preventDefault();
if (!form.text.trim()) return;
addPrayer({ text: form.text, forWhom: form.forWhom, verses: parsed.verses });
setForm({ text: '', forWhom: '', verses: '' });
setTab('open');
};
const actions = {
edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer,
answer: (id, note) => { answerPrayer(id, note); toast('Marked answered. Thank God for it.'); },
remove: (id) => { const undo = deletePrayer(id); toast('Prayer deleted', { action: { label: 'Undo', onClick: undo } }); },
};
const openVerse = (book, chapter, verse) => { jumpToReaderVerse(book, chapter, verse); openBibleReader(); };
const download = () => {
const url = URL.createObjectURL(new Blob([prayersToMarkdown(prayers)], { type: 'text/markdown;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = 'prayer-journal.md';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
return (
<PageShell title="Prayer journal" active="prayer" maxWidth="max-w-3xl">
<main className="mx-auto max-w-3xl space-y-5 px-4 py-6">
<form onSubmit={add} className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4" aria-label="Add a prayer">
<label className="block text-sm font-medium">What are you praying about?
<textarea value={form.text} onChange={(e) => setForm((f) => ({ ...f, text: e.target.value }))} rows={3} maxLength={2000} className={`${field} mt-1 w-full`} placeholder="A request, a thanks, someone to keep in prayer…" />
</label>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<label className="block text-xs text-[var(--r-sub)]">For (optional)
<input type="text" value={form.forWhom} onChange={(e) => setForm((f) => ({ ...f, forWhom: e.target.value }))} maxLength={80} placeholder="e.g. Sam, my church" className={`${field} mt-1 w-full`} />
</label>
<label className="block text-xs text-[var(--r-sub)]">Verses (optional)
<input type="text" value={form.verses} onChange={(e) => setForm((f) => ({ ...f, verses: e.target.value }))} placeholder="e.g. Philippians 4:6, Psalm 23" className={`${field} mt-1 w-full`} />
</label>
</div>
{(parsed.verses.length > 0 || parsed.unknown.length > 0) && (
<p className="mt-2 text-xs text-[var(--r-sub)]">
{parsed.verses.length > 0 && <>Linked: {parsed.verses.map(labelFor).join(' · ')}. </>}
{parsed.unknown.length > 0 && <span className="text-[var(--r-danger,#b42318)]">Couldn’t read: {parsed.unknown.join(', ')}.</span>}
</p>
)}
<button type="submit" disabled={!form.text.trim()} className="mt-3 rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">Add to my journal</button>
</form>
<div className="flex flex-wrap items-center gap-2">
<div role="tablist" aria-label="Which prayers" className="flex gap-1.5">
<button type="button" role="tab" aria-selected={tab === 'open'} onClick={() => setTab('open')} className={tabBtn(tab === 'open')}>Praying ({counts.open})</button>
<button type="button" role="tab" aria-selected={tab === 'answered'} onClick={() => setTab('answered')} className={tabBtn(tab === 'answered')}>Answered ({counts.answered})</button>
<button type="button" role="tab" aria-selected={tab === 'all'} onClick={() => setTab('all')} className={tabBtn(tab === 'all')}>All</button>
</div>
<input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search prayers…" aria-label="Search prayers" className={`${field} ml-auto w-48 py-1.5`} />
{live(prayers).length > 0 && <button type="button" onClick={download} className={small}>Download</button>}
</div>
{shown.length === 0 ? (
<p className="rounded-2xl border border-dashed border-[var(--r-line)] p-8 text-center text-sm text-[var(--r-sub)]">
{live(prayers).length === 0 ? 'Nothing here yet. Write down something you’re praying about, and come back to mark how God answers.'
: query ? 'No prayers match that search.' : tab === 'answered' ? 'No answered prayers yet.' : 'Nothing open right now.'}
</p>
) : (
<ul className="space-y-3" aria-label="Prayers">
{shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} />)}
</ul>
)}
</main>
</PageShell>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
import { AppContext } from '../context/AppContext.js';
import { ToastProvider } from '../components/Toasts.jsx';
import PrayerPage from './PrayerPage.jsx';
import { markAnswered, newPrayer } from '../utils/prayers.js';
vi.mock('../components/study/PageShell.jsx', () => ({ default: ({ children }) => <div>{children}</div> }));
const T0 = Date.UTC(2026, 9, 1);
function setup(initial = [], extra = {}) {
const ctx = {
prayers: initial, addPrayer: vi.fn(), editPrayer: vi.fn(), togglePrayed: vi.fn(), answerPrayer: vi.fn(), reopenPrayer: vi.fn(), deletePrayer: vi.fn(() => vi.fn()),
prayerDraft: null, clearPrayerDraft: vi.fn(), jumpToReaderVerse: vi.fn(), openBibleReader: vi.fn(), ...extra,
};
render(<AppContext.Provider value={ctx}><ToastProvider><PrayerPage /></ToastProvider></AppContext.Provider>);
return ctx;
}
test('a prayer is added with who it is for and the verses it is tied to', async () => {
const user = userEvent.setup();
const ctx = setup();
const form = screen.getByRole('form', { name: /add a prayer/i });
await user.type(within(form).getByLabelText(/what are you praying about/i), 'Peace at home');
await user.type(within(form).getByLabelText(/for \(optional\)/i), 'Family');
await user.type(within(form).getByLabelText(/verses/i), 'Philippians 4:6, nonsense words');
expect(within(form).getByText(/Linked: Philippians 4:6/)).toBeInTheDocument();
expect(within(form).getByText(/Couldn’t read: nonsense words/)).toBeInTheDocument();
await user.click(within(form).getByRole('button', { name: /add to my journal/i }));
expect(ctx.addPrayer).toHaveBeenCalledWith({ text: 'Peace at home', forWhom: 'Family', verses: [expect.objectContaining({ book: 'PHP', chapter: 4, verse: 6 })] });
});
test('praying and answered lists are separate, with counts; a verse chip opens the Bible', async () => {
const open = { ...newPrayer({ text: 'Wisdom at work', verses: [{ book: 'JAS', chapter: 1, verse: 5, label: 'James 1:5' }] }, T0) };
const done = markAnswered(newPrayer({ text: 'A new job' }, T0), 'Hired!', T0 + 3 * 86_400_000);
const user = userEvent.setup();
const ctx = setup([open, done]);
expect(screen.getByRole('tab', { name: 'Praying (1)' })).toBeInTheDocument();
expect(screen.getByText('Wisdom at work')).toBeInTheDocument();
expect(screen.queryByText('A new job')).toBeNull();
await user.click(screen.getByRole('button', { name: 'James 1:5' }));
expect(ctx.jumpToReaderVerse).toHaveBeenCalledWith('JAS', 1, 5);
expect(ctx.openBibleReader).toHaveBeenCalled();
await user.click(screen.getByRole('tab', { name: 'Answered (1)' }));
expect(screen.getByText('A new job')).toBeInTheDocument();
expect(screen.getByText(/after 3 days/)).toBeInTheDocument();
expect(screen.getByText('Hired!')).toBeInTheDocument();
});
test('a prayer can be marked answered with a note, prayed for today, and deleted with undo', async () => {
const p = newPrayer({ text: 'Healing' }, T0);
const user = userEvent.setup();
const ctx = setup([p]);
await user.click(screen.getByRole('button', { name: 'I prayed' }));
expect(ctx.togglePrayed).toHaveBeenCalledWith(p.id);
await user.click(screen.getByRole('button', { name: 'Answered' }));
await user.type(screen.getByLabelText('How was it answered?'), 'Better each day');
await user.click(screen.getByRole('button', { name: 'Mark answered' }));
expect(ctx.answerPrayer).toHaveBeenCalledWith(p.id, 'Better each day');
await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(ctx.deletePrayer).toHaveBeenCalledWith(p.id);
expect(await screen.findByRole('button', { name: 'Undo' })).toBeInTheDocument();
});
test('a verse sent from the Reader fills in the verse box; an empty journal explains itself', () => {
const ctx = setup([], { prayerDraft: { label: 'John 3:16' } });
expect(screen.getByLabelText(/verses/i)).toHaveValue('John 3:16');
expect(ctx.clearPrayerDraft).toHaveBeenCalled();
expect(screen.getByText(/Nothing here yet/)).toBeInTheDocument();
});
test('search narrows the list', async () => {
const a = newPrayer({ text: 'Rain for the farm' }, T0);
const b = newPrayer({ text: 'Peace at home' }, T0 + 5);
setup([a, b]);
await userEvent.setup().type(screen.getByLabelText('Search prayers'), 'rain');
expect(screen.getByText('Rain for the farm')).toBeInTheDocument();
expect(screen.queryByText('Peace at home')).toBeNull();
});
+11 -1
View File
@@ -1,5 +1,7 @@
// The reading side of the app: a distraction-free page with slim chrome. Every
// tool (navigation, search, settings, audio, verse study) lives in a sheet.
import { buildVerseIndex, studiesForVerse } from '../utils/studyVerseIndex.js';
import { loadProjectById } from '../utils/projectStorage.js';
import { placeHighlights, isWholeVerse } from '../utils/highlightSpans.js';
import { useState, useEffect, useRef, useMemo, useDeferredValue, useCallback } from 'react';
import { useApp } from '../context/AppContext.js';
@@ -78,7 +80,8 @@ export default function ReaderPage() {
handleSeekAudioTo,
readerInkByPage,
updateReaderPageInk,
readerTextHighlights,
readerTextHighlights, readerBookmarkNotes, setBookmarkNote, setHighlightNote,
projectIndex, openStudyChunk, startPrayerFor,
addTextHighlight,
removeTextHighlight,
restoreTextHighlight,
@@ -428,6 +431,10 @@ export default function ReaderPage() {
const selectedVerse = readerSelectedVerse != null ? readerVerses.find((v) => v.number === readerSelectedVerse) : null;
const verseHighlights = selectedVerse ? (readerTextHighlights || []).filter((h) => h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === selectedVerse.number) : [];
// Your studies that cover the selected verse (looked up only while a verse is selected, and again when your studies change)
const studyIndexKey = (projectIndex ?? []).map((e) => `${e.id}:${e.lastEdited}`).join('|');
const verseIndex = useMemo(() => (selectedVerse ? buildVerseIndex(projectIndex ?? [], loadProjectById) : []), [selectedVerse != null, studyIndexKey]); // eslint-disable-line react-hooks/exhaustive-deps
const studiesHere = selectedVerse ? studiesForVerse(verseIndex, readerBookAbbrev, readerChapter, selectedVerse.number) : [];
const selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null;
const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`;
const audio = {
@@ -568,6 +575,8 @@ export default function ReaderPage() {
crossRefsLoading={readerCrossRefsLoading}
onLoadCrossRefs={() => { if (!readerCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
bookmarkColor={readerBookmarks[selectedKey]}
studiesHere={studiesHere} onOpenStudy={openStudyChunk} onPray={startPrayerFor}
bookmarkNote={readerBookmarkNotes?.[selectedKey] ?? ''} onSetBookmarkNote={(text) => setBookmarkNote(selectedKey, text)} onSetHighlightNote={setHighlightNote}
onToggleBookmark={() => (readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey))}
onCycleColor={() => cycleBookmarkColor(selectedKey)}
onJump={jump}
@@ -640,6 +649,7 @@ export default function ReaderPage() {
onJump={jump}
onRemoveBookmark={(e) => removeBookmark(e.key, e.color)}
onRemoveHighlight={(h) => removeHighlight(h.id)}
bookmarkNotes={readerBookmarkNotes ?? {}} onSetHighlightNote={setHighlightNote} onSetBookmarkNote={setBookmarkNote}
/>
<AudioSheet
open={sheet === 'audio'} onClose={closeSheet}
+14
View File
@@ -87,6 +87,8 @@ export default function SettingsPage() {
reminderTime: authUser?.reminderTime ?? '07:00',
votdEnabled: authUser?.votdEnabled ?? false,
votdTime: authUser?.votdTime ?? '06:30',
prayerEnabled: authUser?.prayerEnabled ?? false,
prayerTime: authUser?.prayerTime ?? '20:00',
});
const [reminderBusy, setReminderBusy] = useState(false);
const [reminderError, setReminderError] = useState('');
@@ -428,6 +430,18 @@ export default function SettingsPage() {
/>
</label>
</div>
<div className="rounded-xl border border-[var(--r-line)] p-3">
<label className="flex items-center gap-2 text-sm font-medium text-[var(--r-text)]">
<input type="checkbox" checked={reminderForm.prayerEnabled} onChange={(e) => setReminderForm((f) => ({ ...f, prayerEnabled: e.target.checked }))} className="h-4 w-4 accent-[var(--r-accent)]" />
Daily prayer reminder
</label>
<label className="mt-2 block max-w-xs text-sm font-medium text-[var(--r-text)]">
Remind me at
<input type="time" value={reminderForm.prayerTime} onChange={(e) => setReminderForm((f) => ({ ...f, prayerTime: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
</label>
<span className="mt-1 block text-xs text-[var(--r-sub)]">A notification to the same topic listing a few of the requests in your prayer journal. Nothing is sent when there are no open requests.</span>
</div>
{authUser?.votdAvailable && (
<div className="rounded-xl border border-[var(--r-line)] p-3">
<label className="flex items-center gap-2 text-sm font-medium text-[var(--r-text)]">
+11
View File
@@ -103,6 +103,12 @@ export async function disableSharing(id) {
return request('DELETE', `/projects/${id}/share`);
}
// ---- earlier versions of a study ----
export async function listStudyVersions(id) { return request('GET', `/projects/${id}/versions`); }
export async function getStudyVersion(id, versionId) { return request('GET', `/projects/${id}/versions/${versionId}`); }
/** Puts an earlier version back; returns the study as it now is. */
export async function restoreStudyVersion(id, versionId) { return request('POST', `/projects/${id}/versions/${versionId}/restore`); }
// ---- co-teachers: other accounts that may edit a study ----
export async function getCollaborators(id) { return request('GET', `/projects/${id}/collaborators`); }
/** A single-use link (valid 7 days) that lets whoever opens it, signed in, edit this study. -> { token, expiresAt } */
@@ -222,6 +228,11 @@ export async function saveRemoteMarks(marks) {
return request('PUT', '/reader/marks', marks);
}
/** The prayer journal: { prayers: [...] }. */
export async function getRemotePrayers() { return request('GET', '/prayers'); }
/** Merges this device's prayers into the account's (the newer version of each wins); returns the combined list. */
export async function saveRemotePrayers(prayers) { return request('PUT', '/prayers', { prayers }); }
/** What the person has read: { chapters, days }. */
export async function getRemoteHistory() { return request('GET', '/reader/history'); }
/** Merges this device's reading history into the account's; returns the combined history. */
+7 -3
View File
@@ -8,7 +8,7 @@ export const BACKUP_APP = 'bible-study-project';
export const BACKUP_VERSION = 1;
// `reader` is what the app holds in memory: { bookmarks, highlights, colorLabels, ink }.
export function buildBackup({ reader, readingPlans, email = null, now = new Date() }) {
export function buildBackup({ reader, readingPlans, prayers = [], email = null, now = new Date() }) {
const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
return {
app: BACKUP_APP,
@@ -18,11 +18,13 @@ export function buildBackup({ reader, readingPlans, email = null, now = new Date
projects,
reader: {
bookmarks: reader?.bookmarks ?? {},
bookmarkNotes: reader?.bookmarkNotes ?? {},
highlights: reader?.highlights ?? [],
colorLabels: reader?.colorLabels ?? {},
ink: reader?.ink ?? {},
},
readingPlans: readingPlans ?? { progress: [], customPlans: [], activity: [] },
prayers: (prayers ?? []).filter((p) => p && !p.deleted),
};
}
@@ -35,12 +37,14 @@ export function validateBackup(data) {
const projects = Array.isArray(data.projects) ? data.projects.filter((p) => p && typeof p.id === 'string' && p.title && Array.isArray(p.chapters)) : [];
const reader = data.reader && typeof data.reader === 'object' ? data.reader : {};
const bookmarks = reader.bookmarks && typeof reader.bookmarks === 'object' && !Array.isArray(reader.bookmarks) ? reader.bookmarks : {};
const bookmarkNotes = reader.bookmarkNotes && typeof reader.bookmarkNotes === 'object' && !Array.isArray(reader.bookmarkNotes) ? reader.bookmarkNotes : {};
const highlights = Array.isArray(reader.highlights) ? reader.highlights.filter((h) => h && typeof h.id === 'string') : [];
const colorLabels = reader.colorLabels && typeof reader.colorLabels === 'object' && !Array.isArray(reader.colorLabels) ? reader.colorLabels : {};
const ink = reader.ink && typeof reader.ink === 'object' && !Array.isArray(reader.ink) ? reader.ink : {};
const plans = data.readingPlans && typeof data.readingPlans === 'object' ? data.readingPlans : {};
const prayers = Array.isArray(data.prayers) ? data.prayers.filter((p) => p && typeof p.id === 'string' && typeof p.text === 'string' && p.text.trim() && !p.deleted) : [];
const backup = {
projects, reader: { bookmarks, highlights, colorLabels, ink },
projects, prayers, reader: { bookmarks, bookmarkNotes, highlights, colorLabels, ink },
readingPlans: {
progress: Array.isArray(plans.progress) ? plans.progress : [],
customPlans: Array.isArray(plans.customPlans) ? plans.customPlans : [],
@@ -51,7 +55,7 @@ export function validateBackup(data) {
ok: true, backup,
summary: {
projects: projects.length, bookmarks: Object.keys(bookmarks).length, highlights: highlights.length,
inkPages: Object.keys(ink).length, plans: backup.readingPlans.progress.length + backup.readingPlans.customPlans.length,
inkPages: Object.keys(ink).length, prayers: prayers.length, plans: backup.readingPlans.progress.length + backup.readingPlans.customPlans.length,
},
};
}
+13 -1
View File
@@ -20,7 +20,7 @@ test('a backup holds the studies, marks and plans, and round-trips through valid
expect(backup.projects.map((p) => p.id)).toEqual(['p1']);
const checked = validateBackup(JSON.parse(JSON.stringify(backup)));
expect(checked.ok).toBe(true);
expect(checked.summary).toEqual({ projects: 1, bookmarks: 1, highlights: 1, inkPages: 1, plans: 1 });
expect(checked.summary).toEqual({ projects: 1, bookmarks: 1, highlights: 1, inkPages: 1, prayers: 0, plans: 1 });
expect(backupFileName(new Date('2026-10-01T12:00:00Z'))).toBe('bible-study-backup-2026-10-01.json');
});
@@ -56,3 +56,15 @@ test('merging plans keeps every run once, preferring the later activity', () =>
expect(merged.customPlans.map((p) => p.id)).toEqual(['c1', 'c2']);
expect(merged.activity).toEqual(['2026-09-01', '2026-09-02']);
});
test('prayers are in the backup, deleted ones are not, and they survive validation', () => {
const prayers = [{ id: 'a', text: 'Peace', forWhom: '', verses: [], prayedOn: [], createdAt: 1, updatedAt: 2, answeredAt: null, answerNote: '' }, { id: 'b', deleted: true, updatedAt: 3 }];
const backup = buildBackup({ reader: {}, readingPlans: null, prayers });
expect(backup.prayers.map((p) => p.id)).toEqual(['a']);
const checked = validateBackup(JSON.parse(JSON.stringify(backup)));
expect(checked.ok).toBe(true);
expect(checked.summary.prayers).toBe(1);
expect(checked.backup.prayers[0].text).toBe('Peace');
expect(validateBackup({ app: 'bible-study-project', version: 1, prayers: [{ id: 'x' }, 5, { id: 'y', text: ' ' }] }).backup.prayers).toEqual([]);
});
+17 -7
View File
@@ -3,17 +3,17 @@
import { COLOR_NAMES, HIGHLIGHT_COLORS, colorTitle } from './highlightColors.js';
// highlights: [{ bookName, bookIndex, chapter, verse, startOffset, color, text, translation }]; bookmarks: [{ bookName, bookIndex, chapter, verse, color }]
export function buildMarksModel({ highlights = [], bookmarks = [], colorLabels = {} }) {
export function buildMarksModel({ highlights = [], bookmarks = [], colorLabels = {}, bookmarkNotes = {} }) {
const order = (a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse || (a.startOffset ?? 0) - (b.startOffset ?? 0);
const colors = [...new Set([...HIGHLIGHT_COLORS, ...highlights.map((h) => h.color)])];
const groups = colors
.map((color) => ({
color,
title: colorTitle(color, colorLabels),
items: highlights.filter((h) => h.color === color).sort(order).map((h) => ({ ref: `${h.bookName} ${h.chapter}:${h.verse}`, text: (h.text ?? '').trim(), translation: h.translation ?? '' })),
items: highlights.filter((h) => h.color === color).sort(order).map((h) => ({ ref: `${h.bookName} ${h.chapter}:${h.verse}`, text: (h.text ?? '').trim(), translation: h.translation ?? '', note: (h.note ?? '').trim() })),
}))
.filter((g) => g.items.length > 0);
const marks = bookmarks.slice().sort(order).map((b) => ({ ref: `${b.bookName} ${b.chapter}:${b.verse}`, color: COLOR_NAMES[b.color] ?? '' }));
const marks = bookmarks.slice().sort(order).map((b) => ({ ref: `${b.bookName} ${b.chapter}:${b.verse}`, color: COLOR_NAMES[b.color] ?? '', note: (bookmarkNotes[b.key ?? `${b.bookAbbrev}-${b.chapter}-${b.verse}`] ?? '').trim() }));
return { groups, bookmarks: marks };
}
@@ -21,12 +21,18 @@ export function marksToMarkdown(model, { title = 'My highlights and bookmarks',
const out = [`# ${title}`, '', `*${date.toLocaleDateString()}*`, ''];
for (const g of model.groups) {
out.push(`## ${g.title}`, '');
for (const item of g.items) out.push(`- **${item.ref}**${item.translation ? ` (${item.translation})` : ''}${item.text ? ` — “${item.text.replace(/\s+/g, ' ')}”` : ''}`);
for (const item of g.items) {
out.push(`- **${item.ref}**${item.translation ? ` (${item.translation})` : ''}${item.text ? ` — “${item.text.replace(/\s+/g, ' ')}”` : ''}`);
if (item.note) out.push(` - *${item.note.replace(/\s*\n\s*/g, ' ')}*`);
}
out.push('');
}
if (model.bookmarks.length) {
out.push('## Bookmarks', '');
for (const b of model.bookmarks) out.push(`- ${b.ref}${b.color ? ` (${b.color.toLowerCase()})` : ''}`);
for (const b of model.bookmarks) {
out.push(`- ${b.ref}${b.color ? ` (${b.color.toLowerCase()})` : ''}`);
if (b.note) out.push(` - *${b.note.replace(/\s*\n\s*/g, ' ')}*`);
}
out.push('');
}
if (!model.groups.length && !model.bookmarks.length) out.push('Nothing marked yet.', '');
@@ -46,13 +52,17 @@ export async function buildMarksDocxBlob(model, { title = 'My highlights and boo
children.push(new Paragraph({
bullet: { level: 0 },
children: [new TextRun({ text: item.ref, bold: true }), ...(item.translation ? [new TextRun({ text: ` (${item.translation})`, color: '7A7060' })] : []), ...(item.text ? [new TextRun({ text: ` — “${item.text.replace(/\s+/g, ' ')}”` })] : [])],
spacing: { after: 60 },
spacing: { after: item.note ? 20 : 60 },
}));
if (item.note) children.push(new Paragraph({ bullet: { level: 1 }, children: [new TextRun({ text: item.note, italics: true, color: '5A5246' })], spacing: { after: 60 } }));
}
}
if (model.bookmarks.length) {
children.push(heading('Bookmarks'));
for (const b of model.bookmarks) children.push(new Paragraph({ bullet: { level: 0 }, children: [new TextRun({ text: b.ref }), ...(b.color ? [new TextRun({ text: ` (${b.color.toLowerCase()})`, color: '7A7060' })] : [])], spacing: { after: 40 } }));
for (const b of model.bookmarks) {
children.push(new Paragraph({ bullet: { level: 0 }, children: [new TextRun({ text: b.ref }), ...(b.color ? [new TextRun({ text: ` (${b.color.toLowerCase()})`, color: '7A7060' })] : [])], spacing: { after: b.note ? 20 : 40 } }));
if (b.note) children.push(new Paragraph({ bullet: { level: 1 }, children: [new TextRun({ text: b.note, italics: true, color: '5A5246' })], spacing: { after: 40 } }));
}
}
return Packer.toBlob(new Document({ sections: [{ children }] }));
}
+14
View File
@@ -31,3 +31,17 @@ test('the Word file is made', async () => {
const blob = await buildMarksDocxBlob(buildMarksModel({ highlights, bookmarks, colorLabels: {} }));
expect(blob.size).toBeGreaterThan(1000);
});
test('notes appear under their highlights and bookmarks', () => {
const model = buildMarksModel({
highlights: [{ ...highlights[0], note: 'This changed how I pray' }],
bookmarks: [{ ...bookmarks[0], key: 'PSA-23-1' }],
bookmarkNotes: { 'PSA-23-1': 'Read at funerals' },
});
expect(model.groups[0].items[0].note).toBe('This changed how I pray');
expect(model.bookmarks[0].note).toBe('Read at funerals');
const md = marksToMarkdown(model);
expect(md).toContain(' - *This changed how I pray*');
expect(md).toContain(' - *Read at funerals*');
});
+84
View File
@@ -0,0 +1,84 @@
// The prayer journal: requests you are praying about, the verses tied to them, the days you prayed, and how
// God answered. Pure helpers for making, changing, merging and finding prayers. A prayer is
// { id, text, forWhom, verses: [{ book, chapter, verse, label }], prayedOn: ['2026-10-06', ...], createdAt, updatedAt,
// answeredAt, answerNote }; a deleted one is just { id, deleted: true, updatedAt } so other devices drop it too.
import { formatPassage, parsePassageRef } from './passageRef.js';
const DAY_MS = 86_400_000;
const KEEP_DELETED_MS = 90 * DAY_MS;
const MAX_DAYS = 400;
const makeId = () => (globalThis.crypto?.randomUUID?.() ?? `p-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`);
export function newPrayer({ text, forWhom = '', verses = [] }, now = Date.now()) {
return { id: makeId(), text: String(text).trim().slice(0, 2000), forWhom: String(forWhom).trim().slice(0, 80), verses: verses.slice(0, 8), prayedOn: [], createdAt: now, updatedAt: now, answeredAt: null, answerNote: '' };
}
export const touch = (prayer, patch, now = Date.now()) => ({ ...prayer, ...patch, updatedAt: Math.max(now, (prayer.updatedAt ?? 0) + 1) });
export const markDeleted = (prayer, now = Date.now()) => ({ id: prayer.id, deleted: true, updatedAt: Math.max(now, (prayer.updatedAt ?? 0) + 1) });
export function togglePrayedToday(prayer, today, now = Date.now()) {
const has = prayer.prayedOn.includes(today);
return touch(prayer, { prayedOn: has ? prayer.prayedOn.filter((d) => d !== today) : [...prayer.prayedOn, today].sort().slice(-MAX_DAYS) }, now);
}
export const markAnswered = (prayer, note, now = Date.now()) => touch(prayer, { answeredAt: now, answerNote: String(note ?? '').trim().slice(0, 1000) }, now);
export const reopen = (prayer, now = Date.now()) => touch(prayer, { answeredAt: null, answerNote: '' }, now);
// Same rules as the server's merge: the newer version of each prayer wins, days prayed are combined, deletions stick.
export function mergePrayers(a, b, now = Date.now()) {
const byId = new Map();
for (const p of [...(a ?? []), ...(b ?? [])]) {
if (!p || typeof p.id !== 'string') continue;
const have = byId.get(p.id);
if (!have) { byId.set(p.id, p); continue; }
const [newer, older] = (p.updatedAt ?? 0) >= (have.updatedAt ?? 0) ? [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 ?? 0) > KEEP_DELETED_MS)).sort((x, y) => (y.updatedAt ?? 0) - (x.updatedAt ?? 0));
}
export const prayersEqual = (a, b) => JSON.stringify(mergePrayers(a, [])) === JSON.stringify(mergePrayers(b, []));
export const live = (list) => (list ?? []).filter((p) => !p.deleted);
// "John 3:16, Philippians 4:6-7; Psalm 23" -> { verses: [{ book, chapter, verse, label }], unknown: ['text we couldn't read'] }
export function parseVerses(input) {
const verses = [];
const unknown = [];
for (const part of String(input ?? '').split(/[,;\n]+/).map((x) => x.trim()).filter(Boolean)) {
const first = parsePassageRef(part.replace(/[-–]\s*\d+\s*$/, ''))[0]; // "4:6-7" -> read as 4:6
if (first && (first.hasChapter || first.verse)) verses.push({ book: first.book, chapter: first.chapter, verse: first.verse, label: part });
else unknown.push(part);
}
return { verses, unknown };
}
export const labelFor = (ref) => ref.label || formatPassage({ bookName: ref.book, chapter: ref.chapter, verse: ref.verse, hasChapter: true });
// tab: 'open' | 'answered' | 'all'
export function filterPrayers(list, { tab = 'open', query = '' } = {}) {
const q = String(query).trim().toLowerCase();
return live(list)
.filter((p) => (tab === 'open' ? !p.answeredAt : tab === 'answered' ? !!p.answeredAt : true))
.filter((p) => !q || `${p.text} ${p.forWhom} ${p.answerNote} ${(p.verses ?? []).map((v) => v.label).join(' ')}`.toLowerCase().includes(q))
.sort((x, y) => (tab === 'answered' ? (y.answeredAt ?? 0) - (x.answeredAt ?? 0) : (y.updatedAt ?? 0) - (x.updatedAt ?? 0)));
}
export const daysToAnswer = (p) => (p.answeredAt ? Math.max(0, Math.round((p.answeredAt - p.createdAt) / DAY_MS)) : null);
export function prayersToMarkdown(list, { date = new Date() } = {}) {
const items = live(list);
const out = ['# Prayer journal', '', `*${date.toLocaleDateString()}*`, ''];
const section = (title, rows) => {
if (!rows.length) return;
out.push(`## ${title}`, '');
for (const p of rows) {
out.push(`- ${p.forWhom ? `**${p.forWhom}:** ` : ''}${p.text.replace(/\s*\n\s*/g, ' ')}${p.verses?.length ? ` (${p.verses.map(labelFor).join('; ')})` : ''}`);
out.push(` - Asked ${new Date(p.createdAt).toLocaleDateString()}${p.prayedOn?.length ? ` · prayed on ${p.prayedOn.length} day${p.prayedOn.length === 1 ? '' : 's'}` : ''}`);
if (p.answeredAt) out.push(` - Answered ${new Date(p.answeredAt).toLocaleDateString()}${p.answerNote ? `: ${p.answerNote.replace(/\s*\n\s*/g, ' ')}` : ''}`);
}
out.push('');
};
section('Praying', items.filter((p) => !p.answeredAt));
section('Answered', items.filter((p) => p.answeredAt));
if (!items.length) out.push('No prayers yet.', '');
return out.join('\n');
}
+70
View File
@@ -0,0 +1,70 @@
import { test, expect } from 'vitest';
import { daysToAnswer, filterPrayers, markAnswered, markDeleted, mergePrayers, newPrayer, parseVerses, prayersToMarkdown, reopen, togglePrayedToday } from './prayers.js';
const DAY = 86_400_000;
const T0 = Date.UTC(2026, 9, 1);
test('a new prayer is trimmed and starts open', () => {
const p = newPrayer({ text: ' Wisdom for the job ', forWhom: ' Sam ' }, T0);
expect(p).toMatchObject({ text: 'Wisdom for the job', forWhom: 'Sam', answeredAt: null, prayedOn: [], createdAt: T0, updatedAt: T0 });
expect(typeof p.id).toBe('string');
});
test('praying today can be switched on and off, and answering or reopening is tracked', () => {
let p = newPrayer({ text: 'x' }, T0);
p = togglePrayedToday(p, '2026-10-02', T0 + 1);
expect(p.prayedOn).toEqual(['2026-10-02']);
p = togglePrayedToday(p, '2026-10-02', T0 + 2);
expect(p.prayedOn).toEqual([]);
p = markAnswered(p, ' He did it ', T0 + 5 * DAY);
expect(p).toMatchObject({ answerNote: 'He did it', answeredAt: T0 + 5 * DAY });
expect(daysToAnswer(p)).toBe(5);
expect(reopen(p, T0 + 6 * DAY)).toMatchObject({ answeredAt: null, answerNote: '' });
expect(daysToAnswer(newPrayer({ text: 'y' }))).toBeNull();
});
test('verses are read from what you type; unreadable bits are reported', () => {
const { verses, unknown } = parseVerses('John 3:16, Philippians 4:6-7; Psalm 23; blah blah');
expect(verses.map((v) => [v.book, v.chapter, v.verse])).toEqual([['JHN', 3, 16], ['PHP', 4, 6], ['PSA', 23, null]]);
expect(verses[1].label).toBe('Philippians 4:6-7');
expect(unknown).toEqual(['blah blah']);
expect(parseVerses('')).toEqual({ verses: [], unknown: [] });
});
test('devices merge: the newer prayer wins, days combine, a deletion sticks', () => {
const a = newPrayer({ text: 'A' }, T0);
const here = [{ ...a, prayedOn: ['2026-10-01'], updatedAt: T0 + 10 }];
const there = [{ ...a, text: 'A (edited)', prayedOn: ['2026-10-02'], updatedAt: T0 + 20 }];
const merged = mergePrayers(here, there, T0 + 30);
expect(merged).toHaveLength(1);
expect(merged[0]).toMatchObject({ text: 'A (edited)', prayedOn: ['2026-10-01', '2026-10-02'] });
const afterDelete = mergePrayers(merged, [markDeleted(a, T0 + 40)], T0 + 50);
expect(afterDelete).toEqual([{ id: a.id, deleted: true, updatedAt: T0 + 40 }]);
expect(mergePrayers(afterDelete, here, T0 + 60)[0].deleted).toBe(true); // an older copy can't bring it back
});
test('the list can be filtered by tab and searched', () => {
const open = newPrayer({ text: 'Peace at home', forWhom: 'Family' }, T0);
const answered = markAnswered(newPrayer({ text: 'A new job', verses: [{ book: 'PHP', chapter: 4, verse: 6, label: 'Philippians 4:6' }] }, T0), 'Hired!', T0 + DAY);
const gone = markDeleted(newPrayer({ text: 'Removed' }, T0), T0 + 5);
const all = [open, answered, gone];
expect(filterPrayers(all, { tab: 'open' })).toEqual([open]);
expect(filterPrayers(all, { tab: 'answered' })).toEqual([answered]);
expect(filterPrayers(all, { tab: 'all' })).toHaveLength(2);
expect(filterPrayers(all, { tab: 'all', query: 'philippians' })).toEqual([answered]); // searches verses
expect(filterPrayers(all, { tab: 'all', query: 'hired' })).toEqual([answered]); // and the answer
expect(filterPrayers(all, { tab: 'all', query: 'family' })).toEqual([open]);
});
test('the Markdown separates what is being prayed for from what was answered', () => {
const open = { ...newPrayer({ text: 'Peace at home', forWhom: 'Family' }, T0), prayedOn: ['2026-10-01', '2026-10-02'] };
const answered = markAnswered(newPrayer({ text: 'A new job' }, T0), 'Hired!', T0 + DAY);
const md = prayersToMarkdown([open, answered], { date: new Date(2026, 9, 6) });
expect(md).toContain('## Praying');
expect(md).toContain('- **Family:** Peace at home');
expect(md).toContain('prayed on 2 days');
expect(md).toContain('## Answered');
expect(md).toContain('Answered');
expect(md).toContain(': Hired!');
expect(prayersToMarkdown([])).toContain('No prayers yet.');
});
+47
View File
@@ -0,0 +1,47 @@
// What is different between a study as it is now and an earlier version of it, in words a person can read.
const FIELD_NAMES = {
observation: 'Observe', interpretation: 'Interpret', application: 'Apply', generalNotes: 'Background', finalScript: 'Script',
discussionQuestions: 'Questions', episodeNumber: 'Session number', episodeTitle: 'Session title',
};
const same = (a, b) => JSON.stringify(a ?? '') === JSON.stringify(b ?? '');
const chunksOf = (project) => {
const out = new Map();
(project?.chapters ?? []).forEach((chapter) => (chapter.chunks ?? []).forEach((chunk) => out.set(chunk.id, { chunk, ref: `${chapter.book} ${chapter.chapter}:${chunk.startVerse}–${chunk.endVerse}` })));
return out;
};
// current vs older -> { titleChanged, changed: [{ ref, fields }], removedSince: [ref], addedSince: [ref], identical }
// "removedSince" = chunks the older version has that the study doesn't now (restoring brings them back);
// "addedSince" = chunks written since (restoring takes them away, though the copy you replace is kept).
export function diffProjects(current, older) {
const now = chunksOf(current);
const then = chunksOf(older);
const changed = [];
const removedSince = [];
const addedSince = [];
for (const [id, { chunk, ref }] of then) {
const mine = now.get(id);
if (!mine) { removedSince.push(ref); continue; }
const fields = Object.keys(FIELD_NAMES).filter((f) => !same(mine.chunk[f], chunk[f])).map((f) => FIELD_NAMES[f]);
if (!same(mine.chunk.tags, chunk.tags)) fields.push('Tags');
if (!same(mine.chunk.crossReferences, chunk.crossReferences)) fields.push('Cross-references');
if (!same(mine.chunk.greekWords, chunk.greekWords)) fields.push('Words');
if (fields.length) changed.push({ ref, fields });
}
for (const [id, { ref }] of now) if (!then.has(id)) addedSince.push(ref);
const titleChanged = (current?.title ?? '') !== (older?.title ?? '');
const outlineChanged = !same(current?.outline, older?.outline);
return { titleChanged, outlineChanged, changed, removedSince, addedSince, identical: !titleChanged && !outlineChanged && !changed.length && !removedSince.length && !addedSince.length };
}
export function describeDiff(diff) {
if (diff.identical) return ['This version is the same as what you have now.'];
const lines = [];
if (diff.titleChanged) lines.push('The title is different.');
for (const c of diff.changed) lines.push(`${c.ref}: ${c.fields.join(', ')} differ${c.fields.length === 1 ? 's' : ''}.`);
if (diff.removedSince.length) lines.push(`Brings back ${diff.removedSince.length} chunk${diff.removedSince.length === 1 ? '' : 's'} deleted since: ${diff.removedSince.join('; ')}.`);
if (diff.addedSince.length) lines.push(`Takes away ${diff.addedSince.length} chunk${diff.addedSince.length === 1 ? '' : 's'} added since: ${diff.addedSince.join('; ')}.`);
if (diff.outlineChanged) lines.push('The whole-study outline is different.');
return lines;
}
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from 'vitest';
import { describeDiff, diffProjects } from './projectDiff.js';
const study = (chunks, extra = {}) => ({ title: 'Romans', chapters: [{ book: 'Romans', chapter: '8', chunks }], ...extra });
const c = (id, start, end, extra = {}) => ({ id, startVerse: start, endVerse: end, observation: '', application: '', tags: [], ...extra });
test('says which notes differ, and which chunks were deleted or added since', () => {
const now = study([c('a', 1, 4, { observation: 'new words', application: 'now' }), c('c', 20, 25)]);
const then = study([c('a', 1, 4, { observation: 'old words' }), c('b', 5, 9, { observation: 'lost' })]);
const d = diffProjects(now, then);
expect(d.changed).toEqual([{ ref: 'Romans 8:1–4', fields: ['Observe', 'Apply'] }]);
expect(d.removedSince).toEqual(['Romans 8:5–9']);
expect(d.addedSince).toEqual(['Romans 8:20–25']);
expect(d.identical).toBe(false);
expect(describeDiff(d)).toEqual([
'Romans 8:1–4: Observe, Apply differ.',
'Brings back 1 chunk deleted since: Romans 8:5–9.',
'Takes away 1 chunk added since: Romans 8:20–25.',
]);
});
test('an unchanged study, a new title and an outline change are each noticed', () => {
const a = study([c('a', 1, 2)]);
expect(diffProjects(a, structuredClone(a)).identical).toBe(true);
expect(describeDiff(diffProjects(a, structuredClone(a)))).toEqual(['This version is the same as what you have now.']);
expect(diffProjects({ ...a, title: 'Romans 8' }, a).titleChanged).toBe(true);
expect(describeDiff(diffProjects({ ...a, outline: [{ id: 'x' }] }, a))).toEqual(['The whole-study outline is different.']);
expect(diffProjects(a, study([c('a', 1, 2, { tags: ['grace'] })])).changed[0].fields).toEqual(['Tags']);
});
+16 -6
View File
@@ -1,15 +1,18 @@
// Bookmarks ({ "JHN-3-16": "#fef08a" }) and text highlights ([{ id, book, chapter, verse,
// startOffset, endOffset, color }]) — helpers for keeping them in sync between devices.
export const emptyMarks = () => ({ bookmarks: {}, highlights: [] });
export const emptyMarks = () => ({ bookmarks: {}, highlights: [], bookmarkNotes: {} });
export const isEmptyMarks = (m) => Object.keys(m.bookmarks).length === 0 && m.highlights.length === 0;
export const isEmptyMarks = (m) => Object.keys(m.bookmarks).length === 0 && m.highlights.length === 0 && Object.keys(m.bookmarkNotes ?? {}).length === 0;
// Forgiving read of whatever came from localStorage or the server.
export function normalizeMarks(raw) {
const bookmarks = raw?.bookmarks && typeof raw.bookmarks === 'object' && !Array.isArray(raw.bookmarks) ? raw.bookmarks : {};
const highlights = Array.isArray(raw?.highlights) ? raw.highlights.filter((h) => h && typeof h.id === 'string') : [];
return { bookmarks, highlights };
const bookmarkNotes = raw?.bookmarkNotes && typeof raw.bookmarkNotes === 'object' && !Array.isArray(raw.bookmarkNotes)
? Object.fromEntries(Object.entries(raw.bookmarkNotes).filter(([, v]) => typeof v === 'string' && v.trim()))
: {};
return { bookmarks, highlights, bookmarkNotes };
}
const sameSpan = (a, b) => a.book === b.book && a.chapter === b.chapter && a.verse === b.verse
@@ -20,21 +23,28 @@ const sameSpan = (a, b) => a.book === b.book && a.chapter === b.chapter && a.ver
// same bookmark has two colours, the server's wins.
export function mergeMarks(local, server) {
const bookmarks = { ...local.bookmarks, ...server.bookmarks };
const bookmarkNotes = { ...(local.bookmarkNotes ?? {}), ...(server.bookmarkNotes ?? {}) };
const highlights = [...server.highlights];
for (const h of local.highlights) {
if (!highlights.some((s) => s.id === h.id || sameSpan(s, h))) highlights.push(h);
// the same highlight on both: keep the server's, but a note written only here isn't lost
const twin = highlights.findIndex((s) => s.id === h.id || sameSpan(s, h));
if (twin < 0) highlights.push(h);
else if (h.note && !highlights[twin].note) highlights[twin] = { ...highlights[twin], note: h.note };
}
return { bookmarks, highlights };
return { bookmarks, highlights, bookmarkNotes };
}
export function marksEqual(a, b) {
const ka = Object.keys(a.bookmarks);
if (ka.length !== Object.keys(b.bookmarks).length) return false;
if (ka.some((k) => a.bookmarks[k] !== b.bookmarks[k])) return false;
const na = a.bookmarkNotes ?? {};
const nb = b.bookmarkNotes ?? {};
if (Object.keys(na).length !== Object.keys(nb).length || Object.keys(na).some((k) => na[k] !== nb[k])) return false;
if (a.highlights.length !== b.highlights.length) return false;
const byId = new Map(b.highlights.map((h) => [h.id, h]));
return a.highlights.every((h) => {
const o = byId.get(h.id);
return o && sameSpan(h, o) && h.color === o.color;
return o && sameSpan(h, o) && h.color === o.color && (h.note ?? '') === (o.note ?? '');
});
}
+16
View File
@@ -36,3 +36,19 @@ test('marksEqual compares by value, not by reference or order', () => {
expect(marksEqual(a, { ...b, highlights: [hl({ id: '1' })] })).toBe(false);
expect(marksEqual(a, { ...b, highlights: [hl({ id: '1', color: '#000000' }), hl({ id: '2', verse: 2 })] })).toBe(false);
});
test('notes on highlights and bookmarks merge without being lost, and count as a change', async () => {
const { mergeMarks, marksEqual, normalizeMarks, isEmptyMarks } = await import('./readerMarks.js');
const h = (id, extra = {}) => ({ id, book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 5, color: '#fef08a', ...extra });
const local = { bookmarks: { 'JHN-3-16': '#fef08a' }, bookmarkNotes: { 'JHN-3-16': 'mine' }, highlights: [h('a', { note: 'only here' })] };
const server = { bookmarks: { 'JHN-3-16': '#fef08a' }, bookmarkNotes: { 'ROM-8-1': 'theirs' }, highlights: [h('a')] };
const merged = mergeMarks(local, server);
expect(merged.bookmarkNotes).toEqual({ 'JHN-3-16': 'mine', 'ROM-8-1': 'theirs' });
expect(merged.highlights).toHaveLength(1);
expect(merged.highlights[0].note).toBe('only here'); // the note written on this device survives
expect(marksEqual(local, server)).toBe(false); // a note alone is a difference worth syncing
expect(marksEqual(merged, structuredClone(merged))).toBe(true);
expect(normalizeMarks({ bookmarkNotes: { a: 'x', b: ' ', c: 3 } }).bookmarkNotes).toEqual({ a: 'x' });
expect(isEmptyMarks({ bookmarks: {}, highlights: [], bookmarkNotes: { a: 'x' } })).toBe(false);
});
+51
View File
@@ -0,0 +1,51 @@
// "Where have I studied this verse?": every chunk of every study of yours, by the passage it covers, so a verse in the
// Reader can list the studies and notes that already deal with it.
const SNIPPET_FIELDS = ['observation', 'interpretation', 'application', 'generalNotes'];
const snippetOf = (chunk) => {
for (const field of SNIPPET_FIELDS) {
const text = String(chunk[field] ?? '').replace(/\s+/g, ' ').replace(/^[-*•–]\s*/, '').trim();
if (text) return text.length > 110 ? `${text.slice(0, 110).replace(/\s+\S*$/, '')}…` : text;
}
return '';
};
const hasNotes = (chunk) => SNIPPET_FIELDS.some((f) => String(chunk[f] ?? '').trim()) || !!String(chunk.finalScript ?? '').trim();
// projectIndex: the dashboard's list [{ id, title }]; load(id) -> the full study or null
// -> [{ projectId, title, chunkId, book, chapter, start, end, ref, snippet, hasNotes }]
export function buildVerseIndex(projectIndex, load) {
const entries = [];
for (const summary of projectIndex ?? []) {
const project = load(summary.id);
if (!project) continue;
const chapters = project.chapters ?? [];
chapters.forEach((chapter, i) => {
const chapterNumber = Number(chapter.chapter);
for (const chunk of chapter.chunks ?? []) {
const base = {
projectId: project.id, title: project.title ?? summary.title ?? '', chunkId: chunk.id,
snippet: snippetOf(chunk), hasNotes: hasNotes(chunk),
};
const refName = chapter.book;
const next = chapters[i + 1];
const spills = Number.isInteger(chunk.spilloverEndVerse) && chunk.spilloverEndVerse > 0 && next && next.bookAbbrev === chapter.bookAbbrev;
const lastVerse = spills ? (chapter.verses?.at(-1)?.number ?? chunk.endVerse) : chunk.endVerse;
entries.push({
...base, book: chapter.bookAbbrev, chapter: chapterNumber, start: chunk.startVerse, end: spills ? lastVerse : chunk.endVerse,
ref: spills ? `${refName} ${chapter.chapter}:${chunk.startVerse}–${next.chapter}:${chunk.spilloverEndVerse}` : `${refName} ${chapter.chapter}:${chunk.startVerse}${chunk.endVerse !== chunk.startVerse ? `–${chunk.endVerse}` : ''}`,
});
if (spills) entries.push({ ...base, book: next.bookAbbrev, chapter: Number(next.chapter), start: 1, end: chunk.spilloverEndVerse, ref: entries.at(-1).ref });
}
});
}
return entries;
}
// The studies that cover this verse, ones with notes first
export function studiesForVerse(entries, book, chapter, verse) {
const seen = new Set();
return entries
.filter((e) => e.book === book && e.chapter === Number(chapter) && e.start <= verse && verse <= e.end)
.filter((e) => { const k = `${e.projectId}/${e.chunkId}`; if (seen.has(k)) return false; seen.add(k); return true; })
.sort((a, b) => Number(b.hasNotes) - Number(a.hasNotes) || a.title.localeCompare(b.title));
}
+43
View File
@@ -0,0 +1,43 @@
import { test, expect } from 'vitest';
import { buildVerseIndex, studiesForVerse } from './studyVerseIndex.js';
const chunk = (id, s, e, x = {}) => ({ id, startVerse: s, endVerse: e, ...x });
const titus = { id: 'p1', title: 'Titus Notes', chapters: [
{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: Array.from({ length: 16 }, (_, i) => ({ number: i + 1 })), chunks: [
chunk('a', 1, 4, { observation: '- Paul calls himself a servant and an apostle' }),
chunk('b', 5, 16, { application: ' ' }),
] },
{ book: 'Titus', bookAbbrev: 'TIT', chapter: '2', verses: [], chunks: [chunk('c', 1, 5)] },
] };
const john = { id: 'p2', title: 'New Birth', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks: [chunk('d', 1, 21, { generalNotes: 'Nicodemus came at night' })] }] };
const load = (id) => ({ p1: titus, p2: john })[id] ?? null;
const list = [{ id: 'p1', title: 'Titus Notes' }, { id: 'p2', title: 'New Birth' }, { id: 'gone', title: 'Missing' }];
test('a verse finds the chunks that cover it, with a snippet of your notes', () => {
const index = buildVerseIndex(list, load);
const hits = studiesForVerse(index, 'TIT', 1, 3);
expect(hits).toHaveLength(1);
expect(hits[0]).toMatchObject({ projectId: 'p1', chunkId: 'a', ref: 'Titus 1:1–4', hasNotes: true, snippet: 'Paul calls himself a servant and an apostle' });
expect(studiesForVerse(index, 'TIT', 1, 9)[0]).toMatchObject({ chunkId: 'b', hasNotes: false, snippet: '' });
expect(studiesForVerse(index, 'TIT', 1, 17)).toEqual([]);
expect(studiesForVerse(index, 'JHN', 3, 16)[0].snippet).toBe('Nicodemus came at night');
expect(studiesForVerse(index, 'ROM', 8, 1)).toEqual([]);
});
test('a chunk that runs on into the next chapter is found from both chapters', () => {
const spill = { id: 'p3', title: 'Spill', chapters: [
{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: Array.from({ length: 36 }, (_, i) => ({ number: i + 1 })), chunks: [chunk('s', 30, 36, { spilloverEndVerse: 4, observation: 'carries on' })] },
{ book: 'John', bookAbbrev: 'JHN', chapter: '4', verses: [], chunks: [] },
] };
const index = buildVerseIndex([{ id: 'p3' }], () => spill);
expect(studiesForVerse(index, 'JHN', 3, 33)[0].ref).toBe('John 3:30–4:4');
expect(studiesForVerse(index, 'JHN', 4, 2)[0].chunkId).toBe('s');
expect(studiesForVerse(index, 'JHN', 4, 5)).toEqual([]);
});
test('studies with notes come first, then by title; a study is listed once per chunk', () => {
const a = { id: 'x', title: 'Zeta', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks: [chunk('1', 1, 20)] }] };
const b = { id: 'y', title: 'Alpha', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks: [chunk('2', 10, 20, { observation: 'notes' })] }] };
const index = buildVerseIndex([{ id: 'x' }, { id: 'y' }], (id) => ({ x: a, y: b })[id]);
expect(studiesForVerse(index, 'JHN', 3, 16).map((e) => e.title)).toEqual(['Alpha', 'Zeta']);
});