diff --git a/src/components/study/CompareSheet.jsx b/src/components/study/CompareSheet.jsx index b2d1326..eed2fd4 100644 --- a/src/components/study/CompareSheet.jsx +++ b/src/components/study/CompareSheet.jsx @@ -1,9 +1,11 @@ // Compare two passages side by side — your notes and the verses — for parallels (the Gospels), repeated themes, // or the same chapter studied twice. Pick any chunk from any of your studies to set beside the one you're on. -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useApp } from '../../context/AppContext.js'; import { loadProjectById } from '../../utils/projectStorage.js'; import { formatChunkReference, getChunkVerseEntries } from '../../utils/bibleUtils.jsx'; +import { READER_TRANSLATIONS, fetchChapter, translationName } from '../../utils/bibleText.js'; +import { alignVerses, translationSpans } from '../../utils/compareTranslations.js'; const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']]; @@ -35,27 +37,122 @@ function Column({ entry }) { ); } +// One chunk's verses in several translations, a row per verse. Free translations load straight away; licensed ones +// come through the server and may be unavailable (no key set), which shows as a note instead of text. +function TranslationsView({ project, chunkEntry }) { + const spans = useMemo(() => (chunkEntry ? translationSpans(project, chunkEntry.chapterIndex, chunkEntry.chunk) : []), [project, chunkEntry]); + const spanKey = spans.map((s) => `${s.book}${s.chapter}:${s.from}-${s.to}`).join(','); + const [picked, setPicked] = useState(() => { + try { const saved = JSON.parse(localStorage.getItem('compare-translations')); if (Array.isArray(saved) && saved.length) return saved; } catch { /* storage unavailable */ } + return [project?.translation ?? 'BSB', 'NET']; + }); + const [loaded, setLoaded] = useState({}); // id -> { status: 'loading'|'ok'|'error', chapters, copyright, error } + const toggle = (id) => setPicked((cur) => { + const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]; + try { localStorage.setItem('compare-translations', JSON.stringify(next)); } catch { /* storage unavailable */ } + return next; + }); + + // The sheet is modal, so the passage can't change while it's open; results are only dropped after it closes. + const alive = useRef(true); + useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); + useEffect(() => { + picked.filter((id) => !loaded[id]).forEach((id) => { + setLoaded((cur) => ({ ...cur, [id]: { status: 'loading' } })); + Promise.all(spans.map((span) => fetchChapter(id, span.bookAbbrev, Number(span.chapter)) + .then((res) => [`${span.book}|${span.chapter}`, res]))) + .then((results) => alive.current && setLoaded((cur) => ({ + ...cur, + [id]: { status: 'ok', chapters: Object.fromEntries(results.map(([k, r]) => [k, r.verses])), copyright: results.map(([, r]) => r.copyright).find(Boolean) ?? '' }, + }))) + .catch((err) => { if (alive.current) setLoaded((cur) => ({ ...cur, [id]: { status: 'error', error: err.message || 'Unable to load.' } })); }); + }); + }, [picked, spanKey]); // eslint-disable-line react-hooks/exhaustive-deps + + const shown = picked.filter((id) => loaded[id]?.status === 'ok'); + const rows = alignVerses(spans, Object.fromEntries(shown.map((id) => [id, loaded[id].chapters]))); + const cols = { gridTemplateColumns: `repeat(${Math.max(1, shown.length)}, minmax(0, 1fr))` }; + + return ( +
+
+ {READER_TRANSLATIONS.map((t) => { + const on = picked.includes(t.id); + return ( + + ); + })} +
+ {picked.map((id) => loaded[id]?.status === 'error' && ( +

{id} isn’t available right now: {loaded[id].error}

+ ))} + {picked.some((id) => loaded[id]?.status === 'loading') &&

Loading…

} + {shown.length === 0 && !picked.some((id) => !loaded[id] || loaded[id].status === 'loading') &&

Pick two or more translations above.

} + {shown.length > 0 && ( +
+
+ {shown.map((id) => {translationName(id)})} +
+ {rows.map((row) => ( +
+ {shown.map((id) => ( +

+ {row.label} + {id} + {row.texts[id] ?? —} +

+ ))} +
+ ))} +
+ {shown.map((id) => { + const t = READER_TRANSLATIONS.find((x) => x.id === id); + return t?.notice || loaded[id].copyright ?

{loaded[id].copyright || t.notice}

: null; + })} +
+
+ )} +
+ ); +} + export default function CompareSheet({ currentChunkId, onClose }) { const { projectIndex, project } = useApp(); const all = useMemo(() => choices(projectIndex, project), [projectIndex, project]); const mine = all.find((c) => c.chunk.id === currentChunkId && c.project.id === project?.id); + const [mode, setMode] = useState('passage'); // 'passage' | 'translations' const [otherKey, setOtherKey] = useState(''); const other = all.find((c) => c.key === otherKey); + const tab = (id) => `rounded-full border px-3 py-1 text-xs ${mode === id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`; return (
-

Compare passages

- - -
-
- - +

Compare

+
+ + +
+ {mode === 'passage' && ( + + )} +
+ {mode === 'passage' ? ( +
+ + +
+ ) : ( +
+

{mine?.label}

+ +
+ )}
); } diff --git a/src/pages/StudyPage.jsx b/src/pages/StudyPage.jsx index 8de0c08..4a4a1ca 100644 --- a/src/pages/StudyPage.jsx +++ b/src/pages/StudyPage.jsx @@ -43,6 +43,18 @@ export default function StudyPage() { const [tab, setTab] = useResourceTab(); const [openWordId, setOpenWordId] = useState(null); const [comparing, setComparing] = useState(false); + // Either half of the right column can be folded away so the other fills it (remembered on this device). + const [folded, setFolded] = useState(() => { + try { const f = JSON.parse(localStorage.getItem('study-folded')); return { notes: !!f?.notes, resources: !!f?.resources && !f?.notes }; } catch { return { notes: false, resources: false }; } + }); + const toggleFold = (which) => setFolded((cur) => { + const other = which === 'notes' ? 'resources' : 'notes'; + // folding the last open half opens the other one instead, so the column is never empty + const next = { ...cur, [which]: !cur[which] }; + if (next[which] && next[other]) next[other] = false; + try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ } + return next; + }); useEffect(() => { setOpenWordId(null); }, [selectedChunk?.id]); const resources = useStudyResources({ @@ -132,20 +144,33 @@ export default function StudyPage() { {selectedChunk && chapter && ( <> -
-

Your notes

-
+
+

+ +

+
startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
-
- +
+

+ +

+
+ +
)} diff --git a/src/test/App.home.test.jsx b/src/test/App.home.test.jsx index dfa2c24..0dd46e7 100644 --- a/src/test/App.home.test.jsx +++ b/src/test/App.home.test.jsx @@ -435,6 +435,19 @@ describe('Study workspace', () => { expect(await screen.findByRole('button', { name: /add to notes/i })).toBeInTheDocument(); }); + test('either half of the right column can be folded, and the other always stays open', async () => { + const user = await studyOneChunk(); + const notes = screen.getByRole('button', { name: /your notes/i }); + const resources = screen.getByRole('button', { name: /commentary & resources/i }); + expect(notes).toHaveAttribute('aria-expanded', 'true'); + await user.click(notes); + expect(notes).toHaveAttribute('aria-expanded', 'false'); + expect(resources).toHaveAttribute('aria-expanded', 'true'); + await user.click(resources); // folding the last open half opens the other one + expect(resources).toHaveAttribute('aria-expanded', 'false'); + expect(notes).toHaveAttribute('aria-expanded', 'true'); + }); + test('people & places: lists what is tagged in the chunk', async () => { const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]); await user.click(screen.getByRole('tab', { name: /people & places/i })); diff --git a/src/utils/compareTranslations.js b/src/utils/compareTranslations.js new file mode 100644 index 0000000..0bc4e4b --- /dev/null +++ b/src/utils/compareTranslations.js @@ -0,0 +1,30 @@ +// Comparing translations of one chunk: which chapter(s) and verses to fetch, and the verses lined up by number. +import { getChunkVerseEntries } from './bibleUtils.jsx'; + +// -> [{ bookAbbrev, book, chapter, from, to }]: one entry per chapter the chunk covers (two when it spills over). +export function translationSpans(project, chapterIndex, chunk) { + const spans = []; + for (const v of getChunkVerseEntries(project, chapterIndex, chunk)) { + const last = spans[spans.length - 1]; + if (last && last.chapter === v.chapter && last.book === v.book) { last.to = Math.max(last.to, v.number); last.from = Math.min(last.from, v.number); continue; } + const chapter = project.chapters.find((c) => c.book === v.book && String(c.chapter) === String(v.chapter)); + spans.push({ bookAbbrev: chapter?.bookAbbrev, book: v.book, chapter: v.chapter, from: v.number, to: v.number }); + } + return spans; +} + +// byTranslation: { BSB: { verses: [{ number, text }] }, … } for the chapters in `spans`; `loaded` is keyed by +// `${chapter}` inside each translation. -> [{ key, label, texts: { BSB: 'text' | null } }] one row per verse, in order. +export function alignVerses(spans, byTranslation) { + const rows = []; + for (const span of spans) { + for (let n = span.from; n <= span.to; n += 1) { + const texts = {}; + for (const [id, chapters] of Object.entries(byTranslation)) { + texts[id] = chapters?.[`${span.book}|${span.chapter}`]?.find((v) => v.number === n)?.text ?? null; + } + rows.push({ key: `${span.book}${span.chapter}:${n}`, label: spans.length > 1 ? `${span.chapter}:${n}` : String(n), texts }); + } + } + return rows; +} diff --git a/src/utils/compareTranslations.test.js b/src/utils/compareTranslations.test.js new file mode 100644 index 0000000..2eb2be6 --- /dev/null +++ b/src/utils/compareTranslations.test.js @@ -0,0 +1,29 @@ +import { test, expect } from 'vitest'; +import { translationSpans, alignVerses } from './compareTranslations.js'; + +const verses = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => ({ number: from + i, text: `v${from + i}` })); +const project = { chapters: [ + { book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: verses(1, 36), chunks: [] }, + { book: 'John', bookAbbrev: 'JHN', chapter: '4', verses: verses(1, 54), chunks: [] }, +] }; + +test('a chunk inside one chapter is a single span', () => { + expect(translationSpans(project, 0, { startVerse: 16, endVerse: 18, spilloverEndVerse: null })) + .toEqual([{ bookAbbrev: 'JHN', book: 'John', chapter: '3', from: 16, to: 18 }]); +}); + +test('a chunk that runs into the next chapter has a span for each', () => { + const spans = translationSpans(project, 0, { startVerse: 35, endVerse: 36, spilloverEndVerse: 2 }); + expect(spans.map((s) => [s.chapter, s.from, s.to])).toEqual([['3', 35, 36], ['4', 1, 2]]); +}); + +test('verses are lined up by number, with null where a translation lacks one', () => { + const spans = [{ book: 'John', chapter: '3', from: 16, to: 17 }]; + const rows = alignVerses(spans, { + BSB: { 'John|3': [{ number: 16, text: 'a' }, { number: 17, text: 'b' }] }, + NET: { 'John|3': [{ number: 16, text: 'c' }] }, + }); + expect(rows.map((r) => r.label)).toEqual(['16', '17']); + expect(rows[0].texts).toEqual({ BSB: 'a', NET: 'c' }); + expect(rows[1].texts).toEqual({ BSB: 'b', NET: null }); +});