IQ Bible on the Study side: commentaries, lexicons, study aids, outline
Build and Push Docker Image / build-and-push (push) Failing after 19s

- Commentary tab: "More sources" switch with notes for the whole chunk and
  "Add to notes" quoting
- Word study: Lexicons button for words with a Strong's number
- New "Study aids" tab (any verse of the chunk; topics add as tags)
- Outline points: "Add from IQ Bible" attaches topics, Gospel parallels,
  manuscript notes and places

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 08:30:56 -04:00
1 parent 8939a7f49f
commit f70f1baf15
12 files changed
+281 -11

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
// The Study page's "Study aids" tab: IQ Bible's aids for a verse of the chunk being studied (the first by
// default). Topics can be added to the chunk as tags with one tap.
import { useState } from 'react';
import IqStudyAids from './IqStudyAids.jsx';
export default function IqChunkAids({ chunk, chapter, updateChunk, notify }) {
const [picked, setPicked] = useState(null);
const last = Math.min(chunk.endVerse, chunk.startVerse + 59);
const verse = picked != null && picked >= chunk.startVerse && picked <= last ? picked : chunk.startVerse;
const verses = Array.from({ length: last - chunk.startVerse + 1 }, (_, i) => chunk.startVerse + i);
const addTag = (name) => {
const tags = chunk.tags ?? [];
if (tags.some((t) => t.toLowerCase() === name.toLowerCase())) { notify(`“${name}” is already a tag.`, 1600); return; }
updateChunk(chunk.id, { tags: [...tags, name] });
notify(`Added the tag “${name}”.`, 1600);
};
return (
<div>
{verses.length > 1 && (
<label className="mb-3 block text-xs text-[var(--r-sub)]">Aids for verse{' '}
<select value={verse} onChange={(e) => setPicked(Number(e.target.value))} className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-xs text-[var(--r-text)]">
{verses.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</label>
)}
<IqStudyAids key={`${chapter.bookAbbrev}${chapter.chapter}${verse}`} book={chapter.bookAbbrev} chapter={Number(chapter.chapter)} verse={verse} onTag={addTag} />
</div>
);
}
+5 -4
View File
@@ -7,13 +7,13 @@ import { listOf, pickIqEntries } from '../../utils/iqbible.js';
const KEY = 'iq-commentary';
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
export default function IqCommentary({ book, chapter, verse }) {
export default function IqCommentary({ book, chapter, verse, endVerse = verse, onQuote }) {
const [chosen, setChosen] = useState(() => { try { return localStorage.getItem(KEY) || 'mhenry'; } catch { return 'mhenry'; } });
const sources = useIq(`books/${book}/commentaries`, { chapter });
const list = listOf(sources.data);
const source = list.find((s) => s.name === chosen) ?? list.find((s) => s.name === 'mhenry') ?? list[0];
const notes = useIq(source ? `commentaries/${source.name}/${book}/${chapter}` : null);
const entries = pickIqEntries(notes.data?.entries, chapter, verse);
const entries = pickIqEntries(notes.data?.entries, chapter, verse, endVerse);
const pick = (name) => { setChosen(name); try { localStorage.setItem(KEY, name); } catch { /* storage unavailable */ } };
const groups = list.reduce((acc, s) => { (acc[s.category || 'Other'] ??= []).push(s); return acc; }, {});
@@ -38,15 +38,16 @@ export default function IqCommentary({ book, chapter, verse }) {
{notes.status === 'loading' && <p className={muted}>Loading…</p>}
{notes.status === 'error' && <p className={muted}>{notes.error}</p>}
{(notes.status === 'empty' || (notes.status === 'ready' && entries.length === 0)) && (
<p className={muted}>{source?.author_name} has no notes at or before verse {verse} in this chapter.</p>
<p className={muted}>{source?.author_name} has no notes at or before {endVerse > verse ? `verses ${verse}–${endVerse}` : `verse ${verse}`} in this chapter.</p>
)}
{entries.map((e, i) => (
<div key={i}>
<p className="mb-1 text-[11px] text-[var(--r-sub,#64748b)]">
{e.verse !== verse || e.verse_end ? `Notes on verse${e.verse_end && e.verse_end !== e.verse ? `s ${e.verse}–${e.verse_end}` : ` ${e.verse}`}` : ''}
{e.verse !== verse || e.verse_end || endVerse > verse ? `Notes on verse${e.verse_end && e.verse_end !== e.verse ? `s ${e.verse}–${e.verse_end}` : ` ${e.verse}`}` : ''}
{e.source_title ? ` · ${e.source_title}` : ''}
</p>
<div className="max-h-96 overflow-y-auto whitespace-pre-line rounded-xl bg-[var(--r-bg,#ffffff)] p-3 text-[13px] leading-relaxed text-[var(--r-text,#334155)]">{e.text}</div>
{onQuote && <button type="button" onClick={() => onQuote(e.text, e.verse, source?.author_name)} className="mt-0.5 text-[11px] text-[var(--r-accent)] opacity-70 hover:underline hover:opacity-100">Add to notes</button>}
</div>
))}
</div>
+55
View File
@@ -0,0 +1,55 @@
// "Add from IQ Bible" for an outline point: pick a topic, Gospel parallel, manuscript note or place for the
// point's passage and it is attached to the point like a cross-reference or word. Nothing is fetched until
// the picker is opened.
import { useState } from 'react';
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
import { listOf } from '../../utils/iqbible.js';
const GOSPELS = ['MAT', 'MRK', 'LUK', 'JHN'];
// -> [{ id, label, detail, group }] from the four responses (each may be absent)
export function aidOptions({ topics, parallels, variants, places }) {
const out = [];
listOf(topics).forEach((t) => t?.name && out.push({ id: `iq:topic:${t.name}`, group: 'Topics', label: t.name, detail: t.label || undefined }));
listOf(parallels).forEach((s) => s?.number && out.push({
id: `iq:harmony:${s.number}`, group: 'Gospel parallels',
label: (s.references ?? []).map((r) => r.citation ?? r).join('; ') || `Harmony §${s.number}`,
detail: s.title ? String(s.title).toLowerCase().replace(/(^|[.\s])([a-z])/g, (m, a, b) => a + b.toUpperCase()) : undefined,
}));
listOf(variants).forEach((v) => v?.title && out.push({ id: `iq:variant:${v.slug ?? v.title}`, group: 'Manuscript notes', label: v.title, detail: v.explanation ? String(v.explanation).slice(0, 240) : undefined }));
listOf(places).forEach((p) => p?.name && out.push({ id: `iq:place:${p.id ?? p.name}`, group: 'Places', label: p.name, detail: [p.place_type, p.modern_name && p.modern_name !== p.name ? `today: ${p.modern_name}` : ''].filter(Boolean).join(' · ') || undefined }));
return out;
}
export default function IqOutlineAttach({ book, chapter, verse, attachedIds, onAttach, selectClass = '' }) {
const on = useIqAvailable();
const [open, setOpen] = useState(false);
const q = (path) => (open ? path : null);
const topics = useIq(q(`topics/${book}/${chapter}/${verse}`));
const parallels = useIq(q(GOSPELS.includes(book) ? 'harmony' : null), { book, chapter });
const variants = useIq(q(`textual-variants/${book}/${chapter}/${verse}`));
const places = useIq(q(`geo/${book}/${chapter}/${verse}`));
if (!on) return null;
if (!open) {
return <button type="button" onClick={() => setOpen(true)} className="mt-2 text-xs font-medium text-[var(--r-accent)] hover:underline">Add from IQ Bible…</button>;
}
const loading = [topics, parallels, variants, places].some((r) => r.status === 'loading');
const options = aidOptions({ topics: topics.data, parallels: parallels.data, variants: variants.data, places: places.data }).filter((o) => !attachedIds.has(o.id));
const groups = options.reduce((acc, o) => { (acc[o.group] ??= []).push(o); return acc; }, {});
return (
<div className="mt-2">
{loading && <p className="text-xs text-[var(--r-sub)]">Loading from IQ Bible…</p>}
{!loading && options.length === 0 && <p className="text-xs text-[var(--r-sub)]">Nothing more to add for this passage.</p>}
{options.length > 0 && (
<select value="" aria-label="Attach from IQ Bible" className={selectClass}
onChange={(e) => { const pick = options.find((o) => o.id === e.target.value); if (pick) onAttach({ id: pick.id, type: 'see', label: pick.label, detail: pick.detail }); }}>
<option value="">Choose a topic, parallel, note or place…</option>
{Object.entries(groups).map(([group, items]) => (
<optgroup key={group} label={group}>{items.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}</optgroup>
))}
</select>
)}
<button type="button" onClick={() => setOpen(false)} className="mt-1 text-[11px] text-[var(--r-sub)] hover:underline">Close</button>
</div>
);
}
+4 -2
View File
@@ -101,7 +101,7 @@ function Harmony({ data, book, chapter }) {
);
}
export default function IqStudyAids({ book, chapter, verse }) {
export default function IqStudyAids({ book, chapter, verse, onTag }) {
return (
<div className="space-y-1.5">
<Aid title="About this chapter" path={`books/${book}/chapters/${chapter}/info`} empty="No chapter overview is available.">
@@ -116,7 +116,9 @@ export default function IqStudyAids({ book, chapter, verse }) {
{(d) => (
<div className="flex flex-wrap gap-1.5">
{listOf(d).slice(0, 40).map((t, i) => (
<span key={i} className="rounded-full border border-[var(--r-line,#cbd5e1)] px-2 py-0.5 text-xs" title={t.label || t.edition}>{t.name}</span>
onTag
? <button key={i} type="button" onClick={() => onTag(t.name)} className="rounded-full border border-[var(--r-line,#cbd5e1)] px-2 py-0.5 text-xs hover:bg-black/5" title={`Add “${t.name}” as a tag on this chunk`}>+ {t.name}</button>
: <span key={i} className="rounded-full border border-[var(--r-line,#cbd5e1)] px-2 py-0.5 text-xs" title={t.label || t.edition}>{t.name}</span>
))}
</div>
)}
+56
View File
@@ -159,3 +159,59 @@ describe('IqDailyCard', () => {
expect(screen.getByText(/Couldn’t load NLT just now/)).toBeInTheDocument();
});
});
describe('Study side', () => {
const chunk = { id: 'c1', startVerse: 14, endVerse: 16, tags: ['love'], generalNotes: '' };
const chapter = { book: 'John', bookAbbrev: 'JHN', chapter: '3' };
test('topics from the aids tab can be added to the chunk as tags (once)', async () => {
const { default: IqChunkAids } = await import('./IqChunkAids.jsx');
routes['/api/iq/topics/JHN/3/14'] = { data: [{ name: 'faith' }, { name: 'love' }] };
const updateChunk = vi.fn();
const notify = vi.fn();
render(<IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />);
const user = userEvent.setup();
await user.click(screen.getByText('Topics on this verse'));
await user.click(await screen.findByRole('button', { name: /\+ faith/ }));
expect(updateChunk).toHaveBeenCalledWith('c1', { tags: ['love', 'faith'] });
await user.click(screen.getByRole('button', { name: /\+ love/ }));
expect(updateChunk).toHaveBeenCalledTimes(1); // already a tag
expect(notify).toHaveBeenLastCalledWith('“love” is already a tag.', 1600);
});
test('the aids can be read for any verse of the chunk', async () => {
const { default: IqChunkAids } = await import('./IqChunkAids.jsx');
routes['/api/iq/topics/JHN/3/16'] = { data: [{ name: 'eternal life' }] };
render(<IqChunkAids chunk={chunk} chapter={chapter} updateChunk={() => {}} notify={() => {}} />);
const user = userEvent.setup();
await user.selectOptions(screen.getByRole('combobox'), '16');
await user.click(screen.getByText('Topics on this verse'));
expect(await screen.findByText('+ eternal life')).toBeInTheDocument();
});
test('outline attachments are offered from topics, parallels and notes, minus those already attached', async () => {
const { aidOptions } = await import('./IqOutlineAttach.jsx');
const options = aidOptions({
topics: { data: [{ name: 'faith' }, { name: 'grace', label: 'Of God' }] },
parallels: { data: [{ number: '72', title: 'FEEDING OF THE FIVE THOUSAND', references: [{ citation: 'Mark 6:30-44' }, { citation: 'John 6:1-13' }] }] },
variants: { data: [{ slug: 'mark-16', title: 'The Longer Ending of Mark', explanation: 'Long.' }] },
places: { data: [{ id: 5, name: 'Capernaum', place_type: 'settlement' }] },
});
expect(options.map((o) => o.group)).toEqual(['Topics', 'Topics', 'Gospel parallels', 'Manuscript notes', 'Places']);
expect(options[2]).toMatchObject({ id: 'iq:harmony:72', label: 'Mark 6:30-44; John 6:1-13', detail: 'Feeding Of The Five Thousand' });
});
test('the outline picker fetches nothing until it is opened, then attaches the choice', async () => {
const { default: IqOutlineAttach } = await import('./IqOutlineAttach.jsx');
routes['/api/iq/topics/JHN/3/14'] = { data: [{ name: 'faith' }, { name: 'grace' }] };
const onAttach = vi.fn();
render(<IqOutlineAttach book="JHN" chapter={3} verse={14} attachedIds={new Set(['iq:topic:grace'])} onAttach={onAttach} />);
const user = userEvent.setup();
const button = await screen.findByRole('button', { name: /add from iq bible/i });
expect(asked.some((u) => u.includes('/topics/'))).toBe(false);
await user.click(button);
await user.selectOptions(await screen.findByLabelText('Attach from IQ Bible'), 'iq:topic:faith');
expect(onAttach).toHaveBeenCalledWith({ id: 'iq:topic:faith', type: 'see', label: 'faith', detail: undefined });
expect(screen.queryByRole('option', { name: 'grace' })).toBeNull(); // already attached
});
});