Teaching tools: word occurrences, blanks handout, templates, series progress, book info, comments
Build and Push Docker Image / build-and-push (push) Successful in 6m41s

- Words tab: "Where else" lists every verse using a Strong's number, by book
  (new /api/strongs/:number/occurrences, indexed from the interlinear files)
- Handout: fill-in-the-blanks layout with a word bank
- Four new study templates: character, topical, word, verse by verse
- Finish: series progress (not started / studied / ready / taught)
- Background: book at a glance for all 66 books
- Chunk comments for co-teachers, merged between copies
- TODO: correct BSB audio note, tick co-teacher comments

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 15:44:37 -04:00
1 parent d9891ff9fe
commit a6af9bd1c6
24 files changed
+669 -8

No files matched your search

+3 -2
View File
@@ -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
+2
View File
@@ -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);
+89
View File
@@ -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.' });
}
});
}
+40
View File
@@ -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);
});
+41
View File
@@ -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 (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Series progress">
<h2 className="text-sm font-medium">Series progress</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">
{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”.
</p>
<div className="mb-3 flex h-2 overflow-hidden rounded-full bg-[var(--r-line)]" role="img" aria-label={STAGES.map((s, i) => `${counts[i]} ${s.toLowerCase()}`).join(', ')}>
{counts.map((n, i) => n > 0 && <span key={STAGES[i]} style={{ width: `${(n / total) * 100}%`, opacity: [0, 0.35, 0.65, 1][i] }} className={i === 0 ? '' : 'bg-[var(--r-accent)]'} />)}
</div>
<ul className="divide-y divide-[var(--r-line)]">
{rows.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 text-sm">
<span className="w-6 shrink-0 text-xs text-[var(--r-sub)]">{r.number}</span>
<span className="min-w-0 flex-1">
{onOpen
? <button type="button" onClick={() => onOpen(r.id)} className="text-left hover:underline">{r.reference}{r.title ? ` · ${r.title}` : ''}</button>
: <span>{r.reference}{r.title ? ` · ${r.title}` : ''}</span>}
</span>
{(r.openFollowUps > 0 || r.openComments > 0) && (
<span className="text-xs text-[var(--r-sub)]">
{[r.openFollowUps > 0 && `${r.openFollowUps} follow-up${r.openFollowUps === 1 ? '' : 's'}`, r.openComments > 0 && `${r.openComments} comment${r.openComments === 1 ? '' : 's'}`].filter(Boolean).join(' · ')}
</span>
)}
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs ${PILL[r.stage]}`}>{STAGES[r.stage]}</span>
</li>
))}
</ul>
</section>
);
}
+40
View File
@@ -0,0 +1,40 @@
// Comments for the people teaching this study together, shown in the notes' "More" area. Open ones can be marked done.
import { useState } from 'react';
import { addComment, removeComment, toggleComment } from '../../utils/chunkComments.js';
const when = (at) => new Date(at).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
export default function ChunkComments({ chunk, updateChunk, author }) {
const comments = chunk.comments ?? [];
const [text, setText] = useState('');
const set = (next) => updateChunk(chunk.id, { comments: next });
const post = () => {
const next = addComment(comments, { by: author, text });
if (next !== comments) { set(next); setText(''); }
};
return (
<div>
<p className="text-xs text-[var(--r-sub)]">Comments <span className="opacity-70">(notes between the people teaching this study; tick one off when it’s dealt with)</span></p>
{comments.length > 0 && (
<ul className="mt-1.5 space-y-1.5" aria-label="Comments">
{comments.map((c) => (
<li key={c.id} className="flex items-start gap-2 rounded-xl border border-[var(--r-line)] px-2.5 py-2 text-sm">
<input type="checkbox" aria-label={`Done: ${c.text}`} checked={!!c.done} onChange={() => set(toggleComment(comments, c.id))} className="mt-1 accent-[var(--r-accent)]" />
<span className="min-w-0 flex-1">
<span className={`block whitespace-pre-wrap ${c.done ? 'text-[var(--r-sub)] line-through' : ''}`}>{c.text}</span>
<span className="text-[11px] text-[var(--r-sub)]">{c.by} · {when(c.at)}</span>
</span>
<button type="button" aria-label={`Delete comment: ${c.text}`} onClick={() => set(removeComment(comments, c.id))} className="text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
</li>
))}
</ul>
)}
<div className="mt-1.5 flex gap-2">
<input aria-label="Add a comment" value={text} onChange={(e) => setText(e.target.value)} maxLength={1000} placeholder="Write a comment, then Enter"
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); post(); } }}
className="min-w-0 flex-1 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" />
<button type="button" onClick={post} disabled={!text.trim()} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50">Post</button>
</div>
</div>
);
}
+10 -2
View File
@@ -3,6 +3,9 @@
import { useEffect, useRef, useState } from 'react';
import SessionNotes from './SessionNotes.jsx';
import FirstDraft from './FirstDraft.jsx';
import ChunkComments from './ChunkComments.jsx';
import { openCommentCount } from '../../utils/chunkComments.js';
import { bookBackground, hasBookInfo } from '../../utils/bookInfo.js';
import ScriptureRefs from '../ScriptureRefs.jsx';
import { usePasteCheck } from './PastePrompt.jsx';
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
@@ -22,7 +25,7 @@ const SECTIONS = [
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify, onPray, focusRequest }) {
export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify, onPray, focusRequest, author, bookAbbrev, bookName }) {
const [open, setOpen] = useState(() => {
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
});
@@ -142,7 +145,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
<div className="rounded-2xl border border-[var(--r-line)]">
<button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm">
<span><span className="font-medium">More</span> <span className="text-xs text-[var(--r-sub)]">background{tags.length ? ` · ${tags.length} tag${tags.length === 1 ? '' : 's'}` : ' and tags'}</span></span>
<span><span className="font-medium">More</span> <span className="text-xs text-[var(--r-sub)]">background{tags.length ? ` · ${tags.length} tag${tags.length === 1 ? '' : 's'}` : ' and tags'}{openCommentCount(chunk.comments) ? ` · ${openCommentCount(chunk.comments)} open comment${openCommentCount(chunk.comments) === 1 ? '' : 's'}` : ''}</span></span>
<span className="text-xs text-[var(--r-sub)]">{more ? '▾' : '▸'}</span>
</button>
{more && (
@@ -153,12 +156,17 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
<ScriptureRefs text={chunk.generalNotes} />
{hasBookInfo(bookAbbrev) && !(chunk.generalNotes ?? '').includes('at a glance') && (
<button type="button" onClick={() => updateChunk(chunk.id, { generalNotes: `${(chunk.generalNotes ?? '').trimEnd()}${chunk.generalNotes?.trim() ? '\n\n' : ''}${bookBackground(bookAbbrev, bookName)}` })}
className="mt-1 block text-xs text-[var(--r-accent)] hover:underline">Add {bookName ?? 'book'} at a glance (author, date, theme)</button>
)}
</label>
<label className="block text-xs text-[var(--r-sub)]">
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
</label>
<ChunkComments chunk={chunk} updateChunk={updateChunk} author={author} />
<FirstDraft chunk={chunk} />
<SessionNotes chunk={chunk} updateChunk={updateChunk} />
{saveTemplate && (
+71
View File
@@ -0,0 +1,71 @@
// "Where else is this word used?": the verses that share a Strong's number, by book, so a word study can show
// how an author (or the whole Bible) uses it. Books open to their verse list; Copy puts the whole list on the clipboard.
import { useEffect, useState } from 'react';
import { getStrongsOccurrences } from '../../syncService.js';
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
// "John 3:16, 17; 4:1" style list for one book.
export function refsText(book) {
const chapters = [];
for (const [chapter, verse] of book.refs) {
const last = chapters[chapters.length - 1];
if (last && last.chapter === chapter) last.verses.push(verse); else chapters.push({ chapter, verses: [verse] });
}
return `${book.name} ${chapters.map((c) => `${c.chapter}:${c.verses.join(', ')}`).join('; ')}`;
}
export function occurrencesText(strongs, data) {
const lines = data.books.map((b) => `${refsText(b)} (${b.count})`);
return `${strongs} appears in ${data.total} verse${data.total === 1 ? '' : 's'}:\n${lines.join('\n')}`;
}
export default function WordOccurrences({ strongs, onClose }) {
const [state, setState] = useState({ status: 'loading' });
const [open, setOpen] = useState(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
let cancelled = false;
setState({ status: 'loading' });
getStrongsOccurrences(strongs).then((r) => {
if (cancelled) return;
setState(r.ok ? { status: 'ready', data: r.data } : { status: 'error', error: r.error });
});
return () => { cancelled = true; };
}, [strongs]);
const copy = async () => {
try { await navigator.clipboard.writeText(occurrencesText(strongs, state.data)); setCopied(true); window.setTimeout(() => setCopied(false), 2000); } catch { /* clipboard unavailable */ }
};
return (
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3" aria-label={`Where ${strongs} is used`}>
<div className="mb-2 flex items-center gap-2">
<p className="text-sm font-medium">Where {strongs} is used</p>
<button type="button" onClick={onClose} className="ml-auto text-xs text-[var(--r-sub)] hover:underline">Close</button>
</div>
{state.status === 'loading' && <p className="text-xs text-[var(--r-sub)]">Counting… (the first look-up takes a few seconds)</p>}
{state.status === 'error' && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{state.error}</p>}
{state.status === 'ready' && (state.data.total === 0
? <p className="text-xs text-[var(--r-sub)]">No verses found for {strongs}.</p>
: (
<>
<p className="mb-2 text-xs text-[var(--r-sub)]">{state.data.total} verse{state.data.total === 1 ? '' : 's'} in {state.data.books.length} book{state.data.books.length === 1 ? '' : 's'}.</p>
<ul className="space-y-1">
{state.data.books.map((b) => (
<li key={b.book} className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)]">
<button type="button" onClick={() => setOpen(open === b.book ? null : b.book)} aria-expanded={open === b.book}
className="flex w-full items-center justify-between px-2.5 py-1.5 text-left text-sm">
<span>{b.name}</span><span className="text-xs text-[var(--r-sub)]">{b.count}</span>
</button>
{open === b.book && <p className="border-t border-[var(--r-line)] px-2.5 py-2 text-xs leading-relaxed">{refsText(b).slice(b.name.length + 1)}</p>}
</li>
))}
</ul>
<button type="button" onClick={copy} className={`${btn} mt-2`}>{copied ? 'Copied!' : 'Copy the list'}</button>
</>
))}
</div>
);
}
@@ -0,0 +1,38 @@
import { test, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import WordOccurrences, { occurrencesText, refsText } from './WordOccurrences.jsx';
import { getStrongsOccurrences } from '../../syncService.js';
vi.mock('../../syncService.js', () => ({ getStrongsOccurrences: vi.fn() }));
const data = { strongs: 'G25', total: 4, books: [
{ book: 'JHN', name: 'John', count: 3, refs: [[3, 16], [3, 35], [14, 15]] },
{ book: 'ROM', name: 'Romans', count: 1, refs: [[5, 8]] },
] };
beforeEach(() => vi.clearAllMocks());
test('refs are grouped by chapter and the copied list names every book', () => {
expect(refsText(data.books[0])).toBe('John 3:16, 35; 14:15');
expect(occurrencesText('G25', data)).toBe('G25 appears in 4 verses:\nJohn 3:16, 35; 14:15 (3)\nRomans 5:8 (1)');
});
test('shows the books with counts and opens one to list its verses', async () => {
getStrongsOccurrences.mockResolvedValue({ ok: true, data });
render(<WordOccurrences strongs="G25" onClose={() => {}} />);
expect(await screen.findByText('4 verses in 2 books.')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /John/ }));
expect(screen.getByText('3:16, 35; 14:15')).toBeInTheDocument();
expect(getStrongsOccurrences).toHaveBeenCalledWith('G25');
});
test('says so when the word is not found, and when the look-up fails', async () => {
getStrongsOccurrences.mockResolvedValueOnce({ ok: true, data: { strongs: 'G1', total: 0, books: [] } });
const { unmount } = render(<WordOccurrences strongs="G1" onClose={() => {}} />);
await waitFor(() => expect(screen.getByText('No verses found for G1.')).toBeInTheDocument());
unmount();
getStrongsOccurrences.mockResolvedValueOnce({ ok: false, error: 'Could not look up that word.' });
render(<WordOccurrences strongs="G2" onClose={() => {}} />);
expect(await screen.findByRole('alert')).toHaveTextContent('Could not look up that word.');
});
+4
View File
@@ -8,6 +8,7 @@ import { normalizeStrongs } from '../../utils/iqbible.js';
import IqLexicon from '../iq/IqLexicon.jsx';
import GrammarPanel from '../GrammarPanel.jsx';
import IqDictionary from '../iq/IqDictionary.jsx';
import WordOccurrences from './WordOccurrences.jsx';
const inputCls = 'mt-1 block w-full 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)]';
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
@@ -16,6 +17,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
const { updateChunkWord, lookupWord, removeGreekWord } = wordStudy;
const iq = useIqAvailable();
const [lexicon, setLexicon] = useState(false);
const [where, setWhere] = useState(false);
const strongs = normalizeStrongs(word.strongNumber || word.query);
const set = (patch) => updateChunkWord(chunkId, word.id, patch);
const field = (label, key, placeholder) => (
@@ -34,6 +36,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
{open && (
<div className="space-y-3 border-t border-[var(--r-line)] px-3 py-3">
{lexicon && iq && strongs && <IqLexicon strongs={strongs} onClose={() => setLexicon(false)} />}
{where && strongs && <WordOccurrences strongs={strongs} onClose={() => setWhere(false)} />}
<label className="block text-xs text-[var(--r-sub)]">Strong’s number or English gloss
<input type="text" value={word.query} onChange={(e) => set({ query: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); lookupWord(chunkId, word.id, 'greek'); } }}
@@ -43,6 +46,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'greek')} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>{word.loading ? 'Looking up...' : 'Look up Greek'}</button>
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'hebrew')} className={btn}>{word.loading ? 'Looking up...' : 'Look up Hebrew'}</button>
{iq && strongs && <button type="button" onClick={() => setLexicon((o) => !o)} aria-pressed={lexicon} className={btn} title="Strong's, BDB, LSJ and Abbott-Smith entries">Lexicons</button>}
{strongs && <button type="button" onClick={() => setWhere((o) => !o)} aria-pressed={where} className={btn} title="Every verse that uses this word, by book">Where else</button>}
{word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber)} className={btn} title="Hear pronunciation">Speak</button>}
<button type="button" onClick={() => removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-[var(--r-danger,#b42318)]`}>Delete</button>
</div>
+3
View File
@@ -5,6 +5,7 @@ import SlidesExport from '../components/SlidesExport.jsx';
import ImportNotes from '../components/ImportNotes.jsx';
import HandoutExport from '../components/HandoutExport.jsx';
import SessionSchedule from '../components/SessionSchedule.jsx';
import SeriesProgress from '../components/SeriesProgress.jsx';
import VersionHistory from '../components/VersionHistory.jsx';
import CoTeachers from '../components/CoTeachers.jsx';
import LinkRules from '../components/LinkRules.jsx';
@@ -63,6 +64,8 @@ export default function ExportPage() {
)}
</section>
<SeriesProgress project={project} />
<HandoutExport project={project} />
<SlidesExport project={project} />
+3 -1
View File
@@ -12,6 +12,7 @@ import StudioShell from '../components/study/StudioShell.jsx';
import OutlineRail from '../components/study/OutlineRail.jsx';
import ScripturePane from '../components/study/ScripturePane.jsx';
import NotesPane from '../components/study/NotesPane.jsx';
import { commentAuthor } from '../utils/chunkComments.js';
import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.jsx';
import CompareSheet from '../components/study/CompareSheet.jsx';
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
@@ -21,7 +22,7 @@ const NOTE_KEYS = { 1: 'observation', 2: 'interpretation', 3: 'application' };
const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people', r: 'readings' };
export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
const { project, allChunks, projectIndex, authUser, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
// Short-lived status line (word suggestions, lookups, …)
const [statusMessage, setStatusMessage] = useState('');
@@ -170,6 +171,7 @@ export default function StudyPage() {
</h2>
<div className={`min-h-0 flex-1 overflow-y-auto border-t border-[var(--r-line)] p-3 ${folded.notes ? 'hidden' : ''}`}>
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} focusRequest={focusRequest}
author={commentAuthor(authUser)} bookAbbrev={project?.chapters?.[chapterIndex]?.bookAbbrev} bookName={project?.chapters?.[chapterIndex]?.book}
onPray={() => startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
</div>
</section>
+4
View File
@@ -319,3 +319,7 @@ export async function adminGetProject(id) {
export async function adminDeleteProject(id) {
return request('DELETE', `/admin/projects/${id}`);
}
// 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`);
}
+80
View File
@@ -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.)`;
}
+14
View File
@@ -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('');
});
+24
View File
@@ -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;
+33
View File
@@ -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);
});
+34 -1
View File
@@ -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()) ? `<span class="blank">${'&nbsp;'.repeat(Math.max(8, part.length + 2))}</span>` : 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 }, () => '<div class="rule"></div>').join('');
return `<section class="page${layout === 'standard' ? '' : ` ${layout}`}">
<header>
@@ -54,7 +84,8 @@ function sessionHtml(project, session, { answerLines, layout = 'standard' }) {
<p class="ref">${esc(reference)} <span>(${esc(project.translation ?? 'BSB')})</span></p>
<p class="meta">Name: ____________________________ &nbsp;&nbsp; Date: ________________</p>
</header>
${verses.length ? `<div class="passage">${verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${esc(v.text)}</p>`).join('')}</div>` : ''}
${verses.length ? `<div class="passage">${verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${layout === 'blanks' && blanks.length ? blankOut(v.text, blanks) : esc(v.text)}</p>`).join('')}</div>` : ''}
${blanks.length ? `<p class="bank"><b>Word bank:</b> ${[...blanks].sort().map(esc).join(' &middot; ')}</p>` : ''}
${outline.length ? `<h2>Outline</h2><ul class="outline">${outline.map(({ node, depth, label }) => `<li style="margin-left:${depth * 1.2}em"><b>${esc(label)}</b> ${esc(node.text || '')}${node.ref ? ` <i>(${esc(node.ref)})</i>` : ''}</li>`).join('')}</ul>` : ''}
${q.items.length ? `<h2>${q.kind === 'questions' ? 'Discussion questions' : 'Think about it'}</h2><ol class="questions">${q.items.map((item) => `<li>${esc(item)}${answerLines ? blank(2) : ''}</li>`).join('')}</ol>` : ''}
${answerLines || layout === 'notes' ? `<h2>Notes</h2>${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; }
+23 -1
View File
@@ -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 <b>', ['grace']);
expect(out.match(/class="blank"/g)).toHaveLength(2);
expect(out).toContain('&amp; peace');
expect(out).toContain('&lt;b&gt;');
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:</b> condemnation');
expect(html).not.toContain('Condemnation and');
});
+1
View File
@@ -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) };
+29
View File
@@ -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 };
}
+38
View File
@@ -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();
});
+44
View File
@@ -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];
+1 -1
View File
@@ -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('');