diff --git a/TODO.md b/TODO.md
index 2dbd48f..339fd19 100644
--- a/TODO.md
+++ b/TODO.md
@@ -15,7 +15,8 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them.
- [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin).
## Teaching together
-- [ ] **Comments between co-teachers.** Notes on a chunk addressed to the other person, instead of editing each other's text.
+- [x] **Comments between co-teachers.** Comments on a chunk (under "More"), merged between copies and ticked off when dealt with.
+ Still to do: address a comment to one person, and notify them.
- [ ] **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
@@ -23,7 +24,7 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them.
## 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.
+ the NET and other translations that have no matching audio (the BSB and ESV already have narration).
- [x] **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
diff --git a/server/index.js b/server/index.js
index 16975d9..550183d 100644
--- a/server/index.js
+++ b/server/index.js
@@ -11,6 +11,7 @@ import { startReminderScheduler } from './reminders.js';
import { startBackupScheduler } from './backups.js';
import { registerBibleRoutes } from './bibleApi.js';
import { registerIqBibleRoutes } from './iqbible.js';
+import { registerStrongsRoutes } from './strongs.js';
import { registerDeviceRoutes } from './devices.js';
import { requireAuth } from './auth.js';
import { userPayload } from './userPayload.js';
@@ -85,6 +86,7 @@ registerProjectRoutes(app);
registerReaderRoutes(app);
registerBibleRoutes(app, requireAuth);
registerIqBibleRoutes(app, requireAuth);
+registerStrongsRoutes(app, requireAuth);
registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany: TOO_MANY });
registerSharingRoutes(app);
registerAdminRoutes(app);
diff --git a/server/strongs.js b/server/strongs.js
new file mode 100644
index 0000000..8123d3d
--- /dev/null
+++ b/server/strongs.js
@@ -0,0 +1,89 @@
+// "Where else does this word appear?": every verse that uses a Strong's number, grouped by book. Built once, in
+// memory, from the interlinear files the Reader already ships (public/interlinear, or dist/interlinear in a build),
+// the first time anyone asks. Greek (G…) and Hebrew (H…) numbers both work.
+import { existsSync } from 'fs';
+import { readdir, readFile } from 'fs/promises';
+import { fileURLToPath } from 'url';
+import { dirname, join } from 'path';
+import { bookNameFor } from './bibleApi.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+// Strong's numbers come as "G4102", "g4102", "H7225" or sometimes with a letter suffix ("H1234a").
+export function normalizeStrongs(input) {
+ const m = String(input ?? '').trim().match(/^([GH])0*(\d{1,5})[a-z]?$/i);
+ return m ? `${m[1].toUpperCase()}${Number(m[2])}` : null;
+}
+
+// One book's interlinear file -> adds each verse it uses a number in to the index (once per verse).
+export function indexBook(index, book, data) {
+ for (const [chapter, verses] of Object.entries(data ?? {})) {
+ for (const [verse, words] of Object.entries(verses ?? {})) {
+ const seen = new Set();
+ for (const w of Array.isArray(words) ? words : []) {
+ const s = normalizeStrongs(w?.s);
+ if (!s || seen.has(s)) continue;
+ seen.add(s);
+ let entry = index.get(s);
+ if (!entry) { entry = { count: 0, books: new Map() }; index.set(s, entry); }
+ let refs = entry.books.get(book);
+ if (!refs) { refs = []; entry.books.set(book, refs); }
+ refs.push([Number(chapter), Number(verse)]);
+ entry.count += 1;
+ }
+ }
+ }
+}
+
+// The answer for one number: { strongs, total, books: [{ book, name, count, refs: [[chapter, verse], ...] }] },
+// books in the order the index was built (Bible order when files are indexed in canon order).
+export function occurrences(index, strongs) {
+ const key = normalizeStrongs(strongs);
+ const entry = key && index.get(key);
+ if (!entry) return { strongs: key, total: 0, books: [] };
+ const books = [...entry.books].map(([book, refs]) => ({
+ book, name: bookNameFor(book) ?? book, count: refs.length,
+ refs: refs.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]),
+ }));
+ return { strongs: key, total: entry.count, books };
+}
+
+// Canon order so the books come back Genesis → Revelation, whatever order the directory lists them.
+const CANON = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV'];
+
+export async function buildIndex(dir) {
+ const index = new Map();
+ const files = new Set(await readdir(dir));
+ for (const book of CANON) {
+ if (!files.has(`${book}.json`)) continue;
+ try { indexBook(index, book, JSON.parse(await readFile(join(dir, `${book}.json`), 'utf8'))); } catch { /* a damaged file just leaves that book out */ }
+ }
+ return index;
+}
+
+function interlinearDir() {
+ return [join(__dirname, '..', 'dist', 'interlinear'), join(__dirname, '..', 'public', 'interlinear')].find(existsSync) ?? null;
+}
+
+let pending = null;
+const getIndex = () => {
+ if (!pending) {
+ const dir = interlinearDir();
+ pending = (dir ? buildIndex(dir) : Promise.resolve(new Map())).catch((err) => { pending = null; throw err; });
+ }
+ return pending;
+};
+
+export function registerStrongsRoutes(app, requireAuth) {
+ // GET /api/strongs/:number/occurrences
+ app.get('/api/strongs/:number/occurrences', requireAuth, async (req, res) => {
+ const key = normalizeStrongs(req.params.number);
+ if (!key) return res.status(400).json({ error: 'Use a Strong’s number like G4102 or H7225.' });
+ try {
+ res.json(occurrences(await getIndex(), key));
+ } catch (err) {
+ console.error('GET /api/strongs occurrences error:', err);
+ res.status(500).json({ error: 'Could not look up that word.' });
+ }
+ });
+}
diff --git a/server/strongs.test.js b/server/strongs.test.js
new file mode 100644
index 0000000..43d12c6
--- /dev/null
+++ b/server/strongs.test.js
@@ -0,0 +1,40 @@
+import { test, expect } from 'vitest';
+import { indexBook, normalizeStrongs, occurrences } from './strongs.js';
+
+test('normalizeStrongs accepts the usual spellings and rejects the rest', () => {
+ expect(normalizeStrongs('g4102')).toBe('G4102');
+ expect(normalizeStrongs(' H07225 ')).toBe('H7225');
+ expect(normalizeStrongs('H1234a')).toBe('H1234');
+ expect(normalizeStrongs('4102')).toBeNull();
+ expect(normalizeStrongs('')).toBeNull();
+ expect(normalizeStrongs(undefined)).toBeNull();
+});
+
+test('occurrences groups verses by book and counts a verse once however often the word repeats', () => {
+ const index = new Map();
+ indexBook(index, 'JHN', {
+ 3: { 16: [{ s: 'G25' }, { s: 'G25' }, { s: 'G2316' }], 17: [{ s: 'G2316' }] },
+ 1: { 1: [{ s: 'G3056' }, { s: 'G2316' }] },
+ });
+ indexBook(index, 'ROM', { 5: { 8: [{ s: 'G25' }, { s: 'G2316' }] } });
+
+ const love = occurrences(index, 'g25');
+ expect(love.total).toBe(2);
+ expect(love.books.map((b) => [b.book, b.name, b.count])).toEqual([['JHN', 'John', 1], ['ROM', 'Romans', 1]]);
+
+ const god = occurrences(index, 'G2316');
+ expect(god.total).toBe(4);
+ expect(god.books[0].refs).toEqual([[1, 1], [3, 16], [3, 17]]); // sorted by chapter then verse
+});
+
+test('an unknown or invalid number comes back empty', () => {
+ const index = new Map();
+ expect(occurrences(index, 'G99999')).toEqual({ strongs: 'G99999', total: 0, books: [] });
+ expect(occurrences(index, 'nonsense')).toEqual({ strongs: null, total: 0, books: [] });
+});
+
+test('malformed interlinear data is skipped, not thrown', () => {
+ const index = new Map();
+ expect(() => indexBook(index, 'GEN', { 1: { 1: null, 2: 'x', 3: [null, { s: 5 }, { s: 'H430' }] } })).not.toThrow();
+ expect(occurrences(index, 'H430').total).toBe(1);
+});
diff --git a/src/components/SeriesProgress.jsx b/src/components/SeriesProgress.jsx
new file mode 100644
index 0000000..b4ad0ed
--- /dev/null
+++ b/src/components/SeriesProgress.jsx
@@ -0,0 +1,41 @@
+// Finish → Series progress: every session of the study with where it stands (studied, ready to teach, taught) and
+// what is still open on it, so a long study doesn't lose track of itself.
+import { seriesProgress, STAGES } from '../utils/seriesProgress.js';
+
+const PILL = ['border border-[var(--r-line)] text-[var(--r-sub)]', 'bg-[var(--r-bar)] border border-[var(--r-line)]', 'border border-[var(--r-accent)] text-[var(--r-accent)]', 'bg-[var(--r-accent)] text-[var(--r-on)]'];
+
+export default function SeriesProgress({ project, onOpen }) {
+ const { rows, counts, taught, total, next } = seriesProgress(project);
+ if (total < 2) return null; // a series needs more than one session
+ return (
+
+
Series progress
+
+ {taught} of {total} session{total === 1 ? '' : 's'} taught
+ {next ? `. Next up: session ${next.number}, ${next.reference}${next.title ? ` (${next.title})` : ''}.` : '. The series is finished.'}
+ {' '}Worked out from what you’ve written: notes make a session “Studied”, an outline, script or questions make it “Ready to teach”, and session notes make it “Taught”.
+
diff --git a/src/syncService.js b/src/syncService.js
index aab6a80..fd14a99 100644
--- a/src/syncService.js
+++ b/src/syncService.js
@@ -318,4 +318,8 @@ export async function adminGetProject(id) {
export async function adminDeleteProject(id) {
return request('DELETE', `/admin/projects/${id}`);
-}
\ No newline at end of file
+}
+// Every verse that uses a Strong's number, grouped by book: { strongs, total, books: [{ book, name, count, refs }] }
+export async function getStrongsOccurrences(strongs) {
+ return request('GET', `/strongs/${encodeURIComponent(strongs)}/occurrences`);
+}
diff --git a/src/utils/bookInfo.js b/src/utils/bookInfo.js
new file mode 100644
index 0000000..3f63570
--- /dev/null
+++ b/src/utils/bookInfo.js
@@ -0,0 +1,80 @@
+// A short "book at a glance" for each book of the Bible, to start a study's background notes. Authors and dates are
+// the traditional or commonly held ones, and scholars differ on many of them, so the text says so.
+// a: author d: approximate date g: kind of writing t: the book's main theme
+const INFO = {
+ GEN: { a: 'Moses (traditional)', d: 'written c. 1445–1405 BC; events from creation to about 1800 BC', g: 'narrative', t: 'God creates, humanity falls, and God begins a plan of blessing through Abraham’s family.' },
+ EXO: { a: 'Moses', d: 'c. 1445–1405 BC', g: 'narrative and law', t: 'God rescues Israel from Egypt, makes a covenant with them, and comes to live among them.' },
+ LEV: { a: 'Moses', d: 'c. 1445–1405 BC', g: 'law', t: 'How a sinful people can live near a holy God: sacrifice, priesthood and holiness.' },
+ NUM: { a: 'Moses', d: 'c. 1445–1405 BC', g: 'narrative and law', t: 'Israel’s failure to trust God in the wilderness, and God’s faithfulness to bring the next generation toward the land.' },
+ DEU: { a: 'Moses', d: 'c. 1405 BC', g: 'Moses’ farewell sermons, in covenant form', t: 'Love and obey the LORD as Israel prepares to enter the land.' },
+ JOS: { a: 'Joshua (traditional)', d: 'c. 1400–1370 BC', g: 'narrative', t: 'God keeps His promise and gives Israel the land.' },
+ JDG: { a: 'Unknown (Samuel is traditional)', d: 'events c. 1380–1050 BC', g: 'narrative', t: 'Israel’s repeated cycle of sin and rescue, and the need for a faithful king.' },
+ RUT: { a: 'Unknown (Samuel is traditional)', d: 'events in the time of the judges', g: 'narrative', t: 'God’s faithful care, and a kinsman-redeemer, in ordinary lives.' },
+ '1SA': { a: 'Unknown', d: 'events c. 1100–1010 BC', g: 'narrative', t: 'Israel asks for a king; Saul fails and David is chosen.' },
+ '2SA': { a: 'Unknown', d: 'events c. 1010–970 BC', g: 'narrative', t: 'David’s reign, the promise of an everlasting throne, and his sin and its consequences.' },
+ '1KI': { a: 'Unknown (Jeremiah is traditional)', d: 'events c. 970–850 BC', g: 'narrative', t: 'Solomon’s glory, the kingdom divided, and the prophets’ call back to the LORD.' },
+ '2KI': { a: 'Unknown (Jeremiah is traditional)', d: 'events c. 850–560 BC', g: 'narrative', t: 'Both kingdoms fall into exile because they would not turn from idolatry.' },
+ '1CH': { a: 'Unknown (Ezra is traditional)', d: 'c. 450–430 BC', g: 'history', t: 'Israel’s story retold for those returning from exile, centered on David and the temple.' },
+ '2CH': { a: 'Unknown (Ezra is traditional)', d: 'c. 450–430 BC', g: 'history', t: 'The kings of Judah, with the temple and faithful worship as the measure of each reign.' },
+ EZR: { a: 'Ezra (traditional)', d: 'events c. 538–458 BC', g: 'history', t: 'The return from exile, rebuilding the temple and renewing the law.' },
+ NEH: { a: 'Nehemiah', d: 'events c. 445–430 BC', g: 'memoir and history', t: 'Rebuilding Jerusalem’s wall and the people’s covenant renewal.' },
+ EST: { a: 'Unknown (Mordecai is a suggestion)', d: 'events c. 483–470 BC', g: 'narrative', t: 'God quietly preserves His people, though He is never named.' },
+ JOB: { a: 'Unknown', d: 'uncertain; events perhaps in the patriarchal age', g: 'wisdom poetry', t: 'Why the righteous suffer, and who God is when we cannot understand.' },
+ PSA: { a: 'David and others (Asaph, the sons of Korah, Moses, Solomon and unnamed writers)', d: 'c. 1400–430 BC', g: 'poetry and songs', t: 'Praise, lament, trust and thanksgiving: Israel’s prayer book.' },
+ PRO: { a: 'Solomon, with other sages', d: 'c. 970–700 BC', g: 'wisdom', t: 'The fear of the LORD is the beginning of wisdom for daily life.' },
+ ECC: { a: 'Solomon (“the Teacher”) by tradition', d: 'c. 935 BC', g: 'wisdom', t: 'Life “under the sun” is fleeting; fear God and keep His commands.' },
+ SNG: { a: 'Solomon', d: 'c. 965 BC', g: 'love poetry', t: 'The beauty of faithful married love.' },
+ ISA: { a: 'Isaiah', d: 'c. 740–680 BC', g: 'prophecy', t: 'Judgment for sin and the promise of a coming Servant-King who brings salvation.' },
+ JER: { a: 'Jeremiah', d: 'c. 627–580 BC', g: 'prophecy', t: 'Judgment on Judah, and the promise of a new covenant.' },
+ LAM: { a: 'Jeremiah (traditional)', d: 'c. 586 BC', g: 'poetry (laments)', t: 'Grief over Jerusalem’s fall, and hope in the LORD’s steadfast love.' },
+ EZK: { a: 'Ezekiel', d: 'c. 593–570 BC', g: 'prophecy', t: 'God’s glory leaves and returns; judgment, then a new heart and a restored people.' },
+ DAN: { a: 'Daniel', d: 'c. 605–530 BC', g: 'narrative and apocalyptic prophecy', t: 'God rules over the nations and keeps His faithful in exile.' },
+ HOS: { a: 'Hosea', d: 'c. 755–710 BC', g: 'prophecy', t: 'Israel’s unfaithfulness and God’s faithful, pursuing love.' },
+ JOL: { a: 'Joel', d: 'uncertain; c. 835 or 500 BC', g: 'prophecy', t: 'The day of the LORD: judgment, repentance and the outpouring of the Spirit.' },
+ AMO: { a: 'Amos', d: 'c. 760–750 BC', g: 'prophecy', t: 'Justice and righteousness, not empty religion.' },
+ OBA: { a: 'Obadiah', d: 'uncertain; probably c. 586 BC', g: 'prophecy', t: 'Judgment on Edom’s pride and cruelty; the LORD’s kingdom will come.' },
+ JON: { a: 'Jonah', d: 'c. 785–760 BC', g: 'narrative', t: 'God’s mercy reaches even Israel’s enemies, and His prophet resists it.' },
+ MIC: { a: 'Micah', d: 'c. 735–700 BC', g: 'prophecy', t: 'Judgment for injustice, and a ruler to come from Bethlehem.' },
+ NAM: { a: 'Nahum', d: 'c. 663–612 BC', g: 'prophecy', t: 'God’s justice falls on Nineveh.' },
+ HAB: { a: 'Habakkuk', d: 'c. 609–598 BC', g: 'prophecy and prayer', t: '“The righteous shall live by faith,” even when God’s ways are hard to understand.' },
+ ZEP: { a: 'Zephaniah', d: 'c. 640–621 BC', g: 'prophecy', t: 'The day of the LORD is coming, and a humble remnant will be gathered.' },
+ HAG: { a: 'Haggai', d: '520 BC', g: 'prophecy', t: 'Put God’s house first and finish rebuilding the temple.' },
+ ZEC: { a: 'Zechariah', d: 'c. 520–480 BC', g: 'prophecy and visions', t: 'Encouragement to rebuild, and the coming king who is humble and brings peace.' },
+ MAL: { a: 'Malachi', d: 'c. 435–420 BC', g: 'prophecy', t: 'Return to the LORD with real faithfulness; a messenger will prepare His way.' },
+ MAT: { a: 'Matthew (an apostle)', d: 'c. AD 55–65', g: 'Gospel', t: 'Jesus is the promised Messiah and King, and His disciples are to make disciples.' },
+ MRK: { a: 'Mark (with Peter’s testimony)', d: 'c. AD 50–60', g: 'Gospel', t: 'Jesus is the Son of God who serves and gives His life as a ransom.' },
+ LUK: { a: 'Luke, a physician and companion of Paul', d: 'c. AD 58–62', g: 'Gospel', t: 'Jesus came to seek and save the lost, for all kinds of people.' },
+ JHN: { a: 'John (an apostle)', d: 'c. AD 85–95', g: 'Gospel', t: 'Jesus is the Son of God; believe in Him and have life.' },
+ ACT: { a: 'Luke', d: 'c. AD 62', g: 'history', t: 'The Spirit spreads the gospel from Jerusalem to Rome through the church.' },
+ ROM: { a: 'Paul', d: 'c. AD 57', g: 'letter', t: 'The gospel: righteousness from God by faith, for Jew and Gentile.' },
+ '1CO': { a: 'Paul', d: 'c. AD 55', g: 'letter', t: 'Correcting a divided and disorderly church by the cross and by love.' },
+ '2CO': { a: 'Paul', d: 'c. AD 56', g: 'letter', t: 'Paul defends his ministry; God’s power shows in weakness.' },
+ GAL: { a: 'Paul', d: 'c. AD 48–55', g: 'letter', t: 'We are justified by faith, not by works of the law.' },
+ EPH: { a: 'Paul', d: 'c. AD 60–62', g: 'letter', t: 'In Christ, God unites Jew and Gentile into one church, and calls it to walk worthily.' },
+ PHP: { a: 'Paul', d: 'c. AD 60–62', g: 'letter', t: 'Joy in Christ, whatever the circumstances.' },
+ COL: { a: 'Paul', d: 'c. AD 60–62', g: 'letter', t: 'Christ is supreme over all and sufficient for us.' },
+ '1TH': { a: 'Paul', d: 'c. AD 50–51', g: 'letter', t: 'Stand firm and live to please God as you wait for Christ’s return.' },
+ '2TH': { a: 'Paul', d: 'c. AD 51–52', g: 'letter', t: 'Keep working and stand firm; the day of the Lord has not yet come.' },
+ '1TI': { a: 'Paul', d: 'c. AD 62–64', g: 'letter', t: 'How to order the church and guard sound teaching.' },
+ '2TI': { a: 'Paul', d: 'c. AD 66–67', g: 'letter', t: 'Paul’s last words: guard the gospel, endure, and preach the word.' },
+ TIT: { a: 'Paul', d: 'c. AD 62–64', g: 'letter', t: 'Sound teaching shows in good works and godly leaders.' },
+ PHM: { a: 'Paul', d: 'c. AD 60–62', g: 'letter', t: 'Receive a runaway slave back as a brother in Christ.' },
+ HEB: { a: 'Unknown', d: 'c. AD 60–69', g: 'letter and sermon', t: 'Jesus is better than the angels, Moses and the priesthood; hold on to Him.' },
+ JAS: { a: 'James, the brother of Jesus', d: 'c. AD 45–49', g: 'letter', t: 'Faith that is real shows itself in works and wise living.' },
+ '1PE': { a: 'Peter (an apostle)', d: 'c. AD 62–64', g: 'letter', t: 'Hope and holy living for Christians suffering as exiles.' },
+ '2PE': { a: 'Peter', d: 'c. AD 64–68', g: 'letter', t: 'Grow in knowledge of Christ and beware false teachers.' },
+ '1JN': { a: 'John (an apostle)', d: 'c. AD 85–95', g: 'letter', t: 'How to know you have eternal life: walk in light, obey, and love.' },
+ '2JN': { a: 'John', d: 'c. AD 85–95', g: 'letter', t: 'Walk in truth and love, and do not welcome false teachers.' },
+ '3JN': { a: 'John', d: 'c. AD 85–95', g: 'letter', t: 'Support those who serve the truth, and watch for the proud.' },
+ JUD: { a: 'Jude, the brother of James', d: 'c. AD 65–80', g: 'letter', t: 'Contend for the faith against those who pervert grace.' },
+ REV: { a: 'John', d: 'c. AD 95', g: 'apocalyptic prophecy and letters', t: 'Jesus Christ wins; stay faithful until He comes and makes all things new.' },
+};
+
+export const hasBookInfo = (abbrev) => Object.hasOwn(INFO, String(abbrev ?? '').toUpperCase());
+
+// The "at a glance" text for the Background notes, or '' for an unknown book.
+export function bookBackground(abbrev, name) {
+ const i = INFO[String(abbrev ?? '').toUpperCase()];
+ if (!i) return '';
+ return `${name ?? abbrev} at a glance\nAuthor: ${i.a}\nDate: ${i.d}\nKind of writing: ${i.g}\nTheme: ${i.t}\n(Authors and dates are traditional or commonly held; scholars differ on some.)`;
+}
diff --git a/src/utils/bookInfo.test.js b/src/utils/bookInfo.test.js
new file mode 100644
index 0000000..d23e639
--- /dev/null
+++ b/src/utils/bookInfo.test.js
@@ -0,0 +1,14 @@
+import { test, expect } from 'vitest';
+import { bookOptions } from './bibleUtils.jsx';
+import { bookBackground, hasBookInfo } from './bookInfo.js';
+
+test('every book of the Bible has an at-a-glance entry', () => {
+ expect(bookOptions.filter((b) => !hasBookInfo(b.abbrev)).map((b) => b.abbrev)).toEqual([]);
+});
+
+test('the background text names the book and its details; an unknown book gives nothing', () => {
+ const text = bookBackground('luk', 'Luke');
+ expect(text).toMatch(/^Luke at a glance\nAuthor: Luke/);
+ expect(text).toContain('Theme:');
+ expect(bookBackground('XXX', 'Nope')).toBe('');
+});
diff --git a/src/utils/chunkComments.js b/src/utils/chunkComments.js
new file mode 100644
index 0000000..cfcc217
--- /dev/null
+++ b/src/utils/chunkComments.js
@@ -0,0 +1,24 @@
+// Comments on a chunk, for the people teaching a study together: "Can we cut this point?", "Add the Romans 5 link".
+// They live on the chunk (chunk.comments) so they sync and merge with the rest of the study, and a comment can be
+// marked done once it has been dealt with.
+import { makeId } from './bibleUtils.jsx';
+
+const MAX_TEXT = 1000;
+const MAX_COMMENTS = 200;
+
+// "sam@example.com" -> "sam"; no account -> "Me"
+export const commentAuthor = (user) => {
+ const local = String(user?.email ?? '').split('@')[0].trim();
+ return local ? local.slice(0, 40) : 'Me';
+};
+
+// The chunk's comments with one more at the end, or the same list when the text is empty.
+export function addComment(comments = [], { by, text, now = Date.now() }) {
+ const t = String(text ?? '').trim().slice(0, MAX_TEXT);
+ if (!t) return comments;
+ return [...comments, { id: makeId(), by: by || 'Me', at: now, text: t, done: false }].slice(-MAX_COMMENTS);
+}
+
+export const toggleComment = (comments = [], id) => comments.map((c) => (c.id === id ? { ...c, done: !c.done } : c));
+export const removeComment = (comments = [], id) => comments.filter((c) => c.id !== id);
+export const openCommentCount = (comments = []) => comments.filter((c) => !c.done).length;
diff --git a/src/utils/chunkComments.test.js b/src/utils/chunkComments.test.js
new file mode 100644
index 0000000..5de1116
--- /dev/null
+++ b/src/utils/chunkComments.test.js
@@ -0,0 +1,33 @@
+import { test, expect } from 'vitest';
+import { addComment, commentAuthor, openCommentCount, removeComment, toggleComment } from './chunkComments.js';
+import { mergeChunk } from './mergeStudies.js';
+
+test('the author is the part of the email before the @, or "Me"', () => {
+ expect(commentAuthor({ email: 'sam@example.com' })).toBe('Sam'.toLowerCase());
+ expect(commentAuthor(null)).toBe('Me');
+ expect(commentAuthor({ email: '' })).toBe('Me');
+});
+
+test('adding trims the text, ignores empty comments, and stamps author and time', () => {
+ const list = addComment([], { by: 'sam', text: ' Cut point 3? ', now: 5 });
+ expect(list).toHaveLength(1);
+ expect(list[0]).toMatchObject({ by: 'sam', at: 5, text: 'Cut point 3?', done: false });
+ expect(addComment(list, { by: 'sam', text: ' ' })).toBe(list);
+});
+
+test('toggling, removing and counting open comments', () => {
+ let list = addComment(addComment([], { by: 'a', text: 'one' }), { by: 'b', text: 'two' });
+ expect(openCommentCount(list)).toBe(2);
+ list = toggleComment(list, list[0].id);
+ expect(openCommentCount(list)).toBe(1);
+ expect(removeComment(list, list[1].id)).toHaveLength(1);
+});
+
+test('merging two copies of a chunk keeps comments from both and the newer done-state', () => {
+ const shared = { id: 'c1', text: 'both', by: 'a', at: 1, done: false };
+ const mine = { id: 'k', editedAt: 20, comments: [{ ...shared, done: true }, { id: 'm', text: 'mine', by: 'a', at: 2, done: false }] };
+ const theirs = { id: 'k', editedAt: 10, comments: [shared, { id: 't', text: 'theirs', by: 'b', at: 3, done: false }] };
+ const { chunk } = mergeChunk(mine, theirs);
+ expect(chunk.comments.map((c) => c.id).sort()).toEqual(['c1', 'm', 't']);
+ expect(chunk.comments.find((c) => c.id === 'c1').done).toBe(true);
+});
diff --git a/src/utils/handout.js b/src/utils/handout.js
index f1db74f..7cf841d 100644
--- a/src/utils/handout.js
+++ b/src/utils/handout.js
@@ -30,12 +30,41 @@ export function questionsFor(chunk) {
return fallback.length ? { kind: 'think', items: fallback } : { kind: 'none', items: [] };
}
+const STOP = new Set(['the','and','but','for','nor','yet','who','whom','whose','that','this','these','those','with','from','into','unto','upon','shall','will','have','has','had','was','were','been','are','not','his','her','him','their','them','they','you','your','our','out','all','when','then','than','what','which','there','here','also','because','therefore','said','says']);
+
+// The words to leave blank in a fill-in passage: the English words you studied in this session (the word-study
+// glosses) that appear in it; if there are none, the longer words that repeat, since a repeated word is usually
+// what the passage is about. Returns lowercase words, most useful first, at most 12.
+export function blankWordsFor(chunk, verses) {
+ const text = verses.map((v) => v.text).join(' ');
+ const counts = new Map();
+ for (const w of text.toLowerCase().match(/[a-z]+(?:['’][a-z]+)?/g) ?? []) counts.set(w, (counts.get(w) ?? 0) + 1);
+ const glossed = (chunk.greekWords ?? []).flatMap((w) => String(w.englishGloss ?? '').toLowerCase().match(/[a-z]+/g) ?? [])
+ .filter((w) => w.length > 2 && !STOP.has(w) && counts.has(w));
+ const chosen = [...new Set(glossed)];
+ if (chosen.length === 0) {
+ const repeated = [...counts].filter(([w, n]) => n >= 2 && w.length >= 5 && !STOP.has(w)).sort((a, b) => b[1] - a[1] || b[0].length - a[0].length).map(([w]) => w);
+ chosen.push(...repeated);
+ }
+ return chosen.slice(0, 12);
+}
+
+// A verse's text with the chosen words replaced by a blank line of about the same length (each occurrence).
+export function blankOut(text, words) {
+ const set = new Set(words);
+ // split keeps the words (odd entries) apart from the punctuation between them, so both are escaped
+ return String(text ?? '').split(/([A-Za-z]+(?:['’][A-Za-z]+)?)/).map((part, i) => (
+ i % 2 === 1 && set.has(part.toLowerCase()) ? `${' '.repeat(Math.max(8, part.length + 2))}` : esc(part)
+ )).join('');
+}
+
// layout: 'standard' (everything), 'questions' (passage + questions only, one page), 'large' (big print), 'notes' (passage and a blank page to write).
export const HANDOUT_LAYOUTS = [
{ id: 'standard', label: 'Standard: passage, outline, questions' },
{ id: 'questions', label: 'Questions only: passage and discussion questions' },
{ id: 'large', label: 'Large print: passage and questions' },
{ id: 'notes', label: 'Note-taking: passage and lots of lines' },
+ { id: 'blanks', label: 'Fill in the blanks: key words left out of the passage' },
];
function sessionHtml(project, session, { answerLines, layout = 'standard' }) {
@@ -46,6 +75,7 @@ function sessionHtml(project, session, { answerLines, layout = 'standard' }) {
const verses = getChunkVerseEntries(project, chapterIndex, chunk);
const outline = layout === 'standard' ? flatten(outlineForSession(project, chunk)) : [];
const q = layout === 'notes' ? { kind: 'none', items: [] } : questionsFor(chunk);
+ const blanks = layout === 'blanks' ? blankWordsFor(chunk, verses) : [];
const blank = (n) => Array.from({ length: n }, () => '').join('');
return `
@@ -54,7 +84,8 @@ function sessionHtml(project, session, { answerLines, layout = 'standard' }) {
${blank(layout === 'notes' ? 16 : q.items.length ? 4 : 10)}` : ''}
@@ -78,6 +109,8 @@ export function buildHandoutHtml(project, ids = null, { answerLines = true, layo
.passage { background: #f6f1e6; border-left: 3px solid #a84e27; padding: .4em 1em; } .passage p { margin: .35em 0; } sup { color: #a84e27; font-size: 70%; font-family: -apple-system, Helvetica, Arial, sans-serif; }
.outline { list-style: none; padding: 0; margin: 0; } .outline li { margin-bottom: .25em; } .outline b { color: #a84e27; }
.questions { padding-left: 1.4em; } .questions li { margin-bottom: .7em; }
+ .blank { display: inline-block; border-bottom: 1.5px solid #1f1b16; line-height: 1; vertical-align: baseline; }
+ .bank { font: 10.5pt -apple-system, Helvetica, Arial, sans-serif; color: #6b6458; margin: .6em 0 0; }
.rule { border-bottom: 1px solid #c9bfa8; height: 1.7em; }
.large { font-size: 17pt; line-height: 1.55; } .large h1 { font-size: 30pt; } .large .rule { height: 2.2em; }
footer { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; color: #9b9484; font: 9pt -apple-system, Helvetica, Arial, sans-serif; }
diff --git a/src/utils/handout.test.js b/src/utils/handout.test.js
index bd0c8cc..b7e3dbf 100644
--- a/src/utils/handout.test.js
+++ b/src/utils/handout.test.js
@@ -1,5 +1,5 @@
import { test, expect } from 'vitest';
-import { buildHandoutHtml, outlineForSession, questionsFor, sessionsFor } from './handout.js';
+import { blankOut, blankWordsFor, buildHandoutHtml, outlineForSession, questionsFor, sessionsFor } from './handout.js';
const chunk = (id, s, e, x = {}) => ({ id, startVerse: s, endVerse: e, observation: '', interpretation: '', application: '', ...x });
const project = {
@@ -62,3 +62,25 @@ test('layouts change what the handout prints', () => {
expect(notes.match(/class="rule"/g).length).toBeGreaterThanOrEqual(16);
expect(large).toContain('class="page large"');
});
+
+test('blank words are the studied glosses that appear in the passage, else the longer words that repeat', () => {
+ const verses = [{ number: 1, text: 'Grace and peace. Grace to you, and peace to everyone.' }, { number: 2, text: 'Walk in grace.' }];
+ expect(blankWordsFor({ greekWords: [{ englishGloss: 'peace' }, { englishGloss: 'absent' }] }, verses)).toEqual(['peace']);
+ expect(blankWordsFor({ greekWords: [] }, verses)).toEqual(['grace', 'peace']);
+ expect(blankWordsFor({}, [{ number: 1, text: 'In the beginning.' }])).toEqual([]);
+});
+
+test('blankOut replaces every occurrence, keeps other words, and escapes the rest', () => {
+ const out = blankOut('Grace & peace; grace again ', ['grace']);
+ expect(out.match(/class="blank"/g)).toHaveLength(2);
+ expect(out).toContain('& peace');
+ expect(out).toContain('<b>');
+ expect(out).not.toMatch(/Grace|grace/);
+});
+
+test('the fill-in-the-blanks layout blanks the passage and prints a sorted word bank', () => {
+ const html = buildHandoutHtml({ ...project, chapters: [{ ...project.chapters[0], verses: [{ number: 1, text: 'Condemnation and condemnation.' }] }] }, ['a'], { layout: 'blanks' });
+ expect(html).toContain('class="blank"');
+ expect(html).toContain('Word bank: condemnation');
+ expect(html).not.toContain('Condemnation and');
+});
diff --git a/src/utils/mergeStudies.js b/src/utils/mergeStudies.js
index bf7bb62..0ff0fd8 100644
--- a/src/utils/mergeStudies.js
+++ b/src/utils/mergeStudies.js
@@ -43,6 +43,7 @@ export function mergeChunk(mine, theirs) {
out.firstDraft = [newer.firstDraft, older.firstDraft].filter(Boolean).sort((a, b) => a.at - b.at)[0];
out.fiveMinutes = union(newer.fiveMinutes, older.fiveMinutes);
out.followUps = mergeById(newer.followUps, older.followUps);
+ if (newer.comments?.length || older.comments?.length) out.comments = mergeById(newer.comments, older.comments).sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
out.recalls = union(newer.recalls, older.recalls);
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
return { chunk: out, changed: !same(out, mine) };
diff --git a/src/utils/seriesProgress.js b/src/utils/seriesProgress.js
new file mode 100644
index 0000000..5b23be5
--- /dev/null
+++ b/src/utils/seriesProgress.js
@@ -0,0 +1,29 @@
+// Where each session of a series stands, worked out from what has been written (nothing extra to keep up to date):
+// Not started -> Studied (notes written) -> Ready to teach (an outline, script or questions) -> Taught (session notes).
+import { sessionsFor, outlineForSession } from './handout.js';
+import { openCommentCount } from './chunkComments.js';
+
+export const STAGES = ['Not started', 'Studied', 'Ready to teach', 'Taught'];
+
+const has = (s) => String(s ?? '').trim().length > 0;
+
+export function stageOf(project, chunk) {
+ if (['teachBack', 'attendees', 'groupNotes'].some((f) => has(chunk[f]))) return 3;
+ if (has(chunk.finalScript) || has(chunk.discussionQuestions) || outlineForSession(project, chunk).length) return 2;
+ if (['observation', 'interpretation', 'application', 'generalNotes'].some((f) => has(chunk[f]))) return 1;
+ return 0;
+}
+
+// One row per session: where it is, and what is still open on it.
+export function seriesProgress(project) {
+ const rows = sessionsFor(project).map(({ chunk, number, reference }) => ({
+ id: chunk.id, number, reference,
+ title: String(chunk.episodeTitle ?? '').trim(),
+ stage: stageOf(project, chunk),
+ openFollowUps: (chunk.followUps ?? []).filter((f) => !f.done).length,
+ openComments: openCommentCount(chunk.comments),
+ }));
+ const counts = STAGES.map((_, i) => rows.filter((r) => r.stage === i).length);
+ const next = rows.find((r) => r.stage < 3) ?? null; // the first session still to teach
+ return { rows, counts, taught: counts[3], total: rows.length, next };
+}
diff --git a/src/utils/seriesProgress.test.js b/src/utils/seriesProgress.test.js
new file mode 100644
index 0000000..1783b94
--- /dev/null
+++ b/src/utils/seriesProgress.test.js
@@ -0,0 +1,38 @@
+import { test, expect } from 'vitest';
+import { seriesProgress, stageOf } from './seriesProgress.js';
+
+const chunk = (id, x = {}) => ({ id, startVerse: 1, endVerse: 2, ...x });
+const project = (chunks, outline = []) => ({ title: 'Romans', outline, chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [], chunks }] });
+
+test('a session moves from not started to taught as things get written', () => {
+ const p = project([chunk('a')]);
+ expect(stageOf(p, chunk('a'))).toBe(0);
+ expect(stageOf(p, chunk('a', { observation: 'Notes' }))).toBe(1);
+ expect(stageOf(p, chunk('a', { observation: 'Notes', discussionQuestions: 'Why?' }))).toBe(2);
+ expect(stageOf(p, chunk('a', { observation: 'Notes', groupNotes: 'They liked it' }))).toBe(3);
+ expect(stageOf(p, chunk('a', { attendees: ' ' }))).toBe(0);
+});
+
+test('an outline point linked to the session counts as ready to teach', () => {
+ const p = project([chunk('a', { application: 'x' })], [{ id: 'o', text: 'Point', chunkId: 'a', children: [] }]);
+ expect(stageOf(p, p.chapters[0].chunks[0])).toBe(2);
+});
+
+test('the series summary counts stages, open follow-ups and comments, and names the next session to teach', () => {
+ const p = project([
+ chunk('a', { groupNotes: 'Done', followUps: [{ id: 'f1', text: 'x', done: false }, { id: 'f2', text: 'y', done: true }] }),
+ chunk('b', { observation: 'Started', comments: [{ id: 'c1', done: false }, { id: 'c2', done: true }] }),
+ chunk('c'),
+ ]);
+ const s = seriesProgress(p);
+ expect(s.counts).toEqual([1, 1, 0, 1]);
+ expect(s.taught).toBe(1);
+ expect(s.total).toBe(3);
+ expect(s.rows[0].openFollowUps).toBe(1);
+ expect(s.rows[1].openComments).toBe(1);
+ expect(s.next.id).toBe('b');
+});
+
+test('a finished series has no next session', () => {
+ expect(seriesProgress(project([chunk('a', { teachBack: 'x' })])).next).toBeNull();
+});
diff --git a/src/utils/studyTemplates.js b/src/utils/studyTemplates.js
index ffe9197..96b861e 100644
--- a/src/utils/studyTemplates.js
+++ b/src/utils/studyTemplates.js
@@ -36,6 +36,50 @@ export const STUDY_TEMPLATES = [
},
starter: '',
},
+ {
+ id: 'character',
+ name: 'Character study',
+ description: 'Follow a person through the text: who they are, what they did, what God did through them.',
+ prompts: {
+ observation: 'Who is this person here?\n• What do we learn about their background, family, and role?\n• What do they say, do, and feel in this passage?\n• How do others describe or respond to them?',
+ interpretation: 'What does their story show?\n• What strengths and failures stand out?\n• What does this reveal about God\'s character and purposes?\n• How does their story connect to the rest of the Bible?',
+ application: 'Where do I see myself?\n• What should I imitate, or avoid?\n• What does God want me to trust Him with?',
+ },
+ starter: 'Who they are (name meaning, family, role):\n\nKey moments in this passage:\n\nStrengths / failures:\n\nWhat God does through them:\n',
+ },
+ {
+ id: 'topical',
+ name: 'Topical study',
+ description: 'Gather what Scripture says about one subject, passage by passage.',
+ prompts: {
+ observation: 'What does this passage say about the topic?\n• Quote or paraphrase the key statement.\n• Who is speaking, and in what situation?\n• Which words tie it to the topic?',
+ interpretation: 'How does this fit the whole picture?\n• Does it agree with, add to, or qualify the other passages?\n• What did it mean in its own context before you apply it?',
+ application: 'What does this change?\n• What do I believe differently now?\n• What will I do about it this week?',
+ },
+ starter: 'Topic:\n\nWhat this passage adds:\n\nCompare with (other passages):\n\nSummary so far:\n',
+ },
+ {
+ id: 'word',
+ name: 'Word study',
+ description: 'Trace one original-language word: its meaning, where it appears, and how the author uses it here.',
+ prompts: {
+ observation: 'Look at the word in this verse.\n• Greek or Hebrew word, Strong\'s number, and how the English renders it.\n• Its grammar (tense, voice, mood, or stem) and what that signals.\n• Where else does this author use it?',
+ interpretation: 'What does it mean here?\n• Which sense of the word fits the context?\n• What would be lost if a different English word were used?',
+ application: 'Why does it matter?\n• What does this word teach me to believe or do?',
+ },
+ starter: 'Word (original, transliteration, Strong\'s):\n\nRange of meanings:\n\nUsed elsewhere (use “Where else” on the Words tab):\n\nMeaning in this verse:\n',
+ },
+ {
+ id: 'verse',
+ name: 'Verse by verse',
+ description: 'Walk slowly through a passage, one verse at a time, then pull it together.',
+ prompts: {
+ observation: 'Verse by verse.\n• Write a short note on each verse: what it says, and what stands out.\n• Mark the connecting words (for, so, but).',
+ interpretation: 'Putting it together.\n• What is the main point of this section?\n• How does each verse support it?\n• How does it fit the verses before and after?',
+ application: 'Respond.\n• What is the one truth to hold on to?\n• What is the one step to take?',
+ },
+ starter: 'Verse notes:\nv. \nv. \nv. \n\nMain point:\n',
+ },
];
export const getTemplate = (id, userTemplates = []) => STUDY_TEMPLATES.find((t) => t.id === id) ?? userTemplates.find((t) => t.id === id) ?? STUDY_TEMPLATES[0];
diff --git a/src/utils/studyTemplates.test.js b/src/utils/studyTemplates.test.js
index 47c85d4..41d0808 100644
--- a/src/utils/studyTemplates.test.js
+++ b/src/utils/studyTemplates.test.js
@@ -10,7 +10,7 @@ test('unknown or missing templates are the blank one', () => {
});
test('templates change the prompts and may pre-fill background notes', () => {
- expect(STUDY_TEMPLATES.map((t) => t.id)).toEqual(['blank', 'inductive', 'sermon', 'devotional']);
+ expect(STUDY_TEMPLATES.map((t) => t.id)).toEqual(['blank', 'inductive', 'sermon', 'devotional', 'character', 'topical', 'word', 'verse']);
expect(templatePrompt('sermon', 'application')).toMatch(/room/);
expect(blankChunk(1, 5, null, 'sermon').generalNotes).toMatch(/Big idea/);
expect(blankChunk(1, 5).generalNotes).toBe('');