-
-
Study Notes
-
Write your observations, cross-references, and teaching notes here.
-
+ {/* OIA Notes */}
+
+
+
Study Notes (OIA)
+
Observation · Interpretation · Application
+
+ {[
+ { field: 'observation', label: 'Observation', placeholder: 'What does the text say? List facts, details, key words…' },
+ { field: 'interpretation', label: 'Interpretation', placeholder: 'What does it mean? Context, cross-references, theology…' },
+ { field: 'application', label: 'Application', placeholder: 'How does it apply? Personal response, life change…' },
+ ].map(({ field, label, placeholder }) => (
+
+
+
-
+
+ {/* Cross-references */}
+
+
Cross-References
+
+ setCrossRefInput(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCrossRef(selectedChunk.id); } }}
+ placeholder="e.g. John 1:1 or Rom 3:23"
+ className="flex-1 rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
+
+ {(selectedChunk.crossReferences ?? []).length > 0 && (
+
+ {selectedChunk.crossReferences.map((ref) => (
+
+ {ref}
+
+
+ ))}
+
+ )}
+
-
-
-
-
Greek Word Studies
-
Add lexical notes, look up Strong's entries, and keep definitions with each chunk.
+ {/* Greek words */}
+
+
+
+
Greek Word Studies
+
Add lexical notes, look up Strong's entries.
+
+
+
+
+ {selectedChunk.greekWords.length === 0 ? (
+
+ No Greek words yet. Add one to begin a lookup.
-
-
-
- {selectedChunk.greekWords.length === 0 ? (
-
- No Greek words yet. Add one to begin a lookup.
-
- ) : (
- selectedChunk.greekWords.map((word) => (
-
-
-
-
-
-
-
+ ) : (
+ selectedChunk.greekWords.map((word) => (
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- {word.definitionHtml ? (
-
-
- Extended definition
-
-
-
- ) : null}
- ))
- )}
-
-
-
+ ))
+ )}
- ) : (
-
- Select a chunk from the left panel to edit its notes and Greek studies.
-
- )}
-
-
- )}
-
+ {/* Prev / Next */}
+
+
+
+
+
+ ) : (
+
+ Select a chunk from the left panel to edit its notes and Greek studies.
+
+ )}
+
+
+
);
};
+// ---------------------------------------------------------------------------
+// SetupForm — extracted to avoid duplication between new-project and add-chapter flows
+// ---------------------------------------------------------------------------
+function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, errorMessage, hideTitle, onField, onTitleChange, onLoad }) {
+ return (
+
+ {!hideTitle && (
+
+
Project Setup
+
+ Pick a translation, chapter, and title. Load the chapter to begin structuring your study into chunks.
+
+
+ )}
+
+
+
+
+ {!hideTitle && (
+
+ )}
+
+
+
+ {errorMessage
+ ? {errorMessage}
+ : 'Start by loading the chapter text from HelloAO.'}
+
+
+
+
+ );
+}
+
export default App;
diff --git a/src/App.test.jsx b/src/App.test.jsx
index 45760b5..5b53a03 100644
--- a/src/App.test.jsx
+++ b/src/App.test.jsx
@@ -62,6 +62,7 @@ afterEach(() => {
async function loadChapter() {
const user = userEvent.setup();
render(
);
+ await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
return user;
@@ -97,14 +98,33 @@ function findChunkCounter(n, total) {
// Initial render
// ---------------------------------------------------------------------------
describe('Initial render', () => {
- test('shows the project setup form', () => {
+ test('shows the home page with "My Studies" heading', () => {
render(
);
+ expect(screen.getByText('My Studies')).toBeInTheDocument();
+ });
+
+ test('shows "No projects yet" when storage is empty', () => {
+ render(
);
+ expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
+ });
+
+ test('shows "New Project" button on home page', () => {
+ render(
);
+ expect(screen.getAllByRole('button', { name: /new project/i }).length).toBeGreaterThan(0);
+ });
+
+ test('clicking "New Project" shows the project setup form', async () => {
+ const user = userEvent.setup();
+ render(
);
+ await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(screen.getByText('Project Setup')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
});
- test('shows translation, book, and chapter inputs', () => {
+ test('setup form shows translation, book, and chapter inputs', async () => {
+ const user = userEvent.setup();
render(
);
+ await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(screen.getByText('Translation')).toBeInTheDocument();
expect(screen.getByText('Book')).toBeInTheDocument();
expect(screen.getByText('Chapter')).toBeInTheDocument();
@@ -139,6 +159,7 @@ describe('Loading a chapter', () => {
}));
const user = userEvent.setup();
render(
);
+ await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/unable to load chapter/i);
});
@@ -147,8 +168,10 @@ describe('Loading a chapter', () => {
vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } }));
const user = userEvent.setup();
render(
);
+ await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/invalid bible data/i);
+
});
});
diff --git a/src/utils.test.js b/src/utils.test.js
index ec9c8e5..21ba68f 100644
--- a/src/utils.test.js
+++ b/src/utils.test.js
@@ -9,6 +9,8 @@ import {
buildExportHtml,
buildClaudePrompt,
createParagraphsFromText,
+ migrateChunk,
+ migrateProject,
} from './App.jsx';
// ---------------------------------------------------------------------------
@@ -248,34 +250,110 @@ describe('wordTableHtml', () => {
});
});
+// ---------------------------------------------------------------------------
+// migrateChunk
+// ---------------------------------------------------------------------------
+describe('migrateChunk', () => {
+ test('converts notes → observation and adds OIA/crossRef fields', () => {
+ const old = { id: 'c1', startVerse: 1, endVerse: 2, notes: 'My notes.', greekWords: [] };
+ const result = migrateChunk(old);
+ expect(result.observation).toBe('My notes.');
+ expect(result.interpretation).toBe('');
+ expect(result.application).toBe('');
+ expect(result.crossReferences).toEqual([]);
+ });
+
+ test('is a no-op when chunk already has observation field', () => {
+ const modern = { id: 'c1', startVerse: 1, endVerse: 1, observation: 'Already migrated.', interpretation: '', application: '', crossReferences: [], greekWords: [] };
+ expect(migrateChunk(modern)).toBe(modern);
+ });
+
+ test('handles missing notes gracefully', () => {
+ const chunk = { id: 'c1', startVerse: 1, endVerse: 1, greekWords: [] };
+ expect(migrateChunk(chunk).observation).toBe('');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// migrateProject
+// ---------------------------------------------------------------------------
+describe('migrateProject', () => {
+ test('wraps old flat project into chapters array', () => {
+ const old = {
+ title: 'Test',
+ translation: 'BSB',
+ book: 'Titus',
+ bookAbbrev: 'TIT',
+ chapter: '1',
+ verses: [{ number: 1, text: 'Hello.' }],
+ chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, notes: 'Note.', greekWords: [] }],
+ };
+ const result = migrateProject(old);
+ expect(Array.isArray(result.chapters)).toBe(true);
+ expect(result.chapters).toHaveLength(1);
+ expect(result.chapters[0].book).toBe('Titus');
+ expect(result.chapters[0].chunks[0].observation).toBe('Note.');
+ });
+
+ test('preserves chapters array if already new format', () => {
+ const modern = {
+ id: 'p1',
+ title: 'Test',
+ translation: 'BSB',
+ chapters: [{
+ book: 'Titus', bookAbbrev: 'TIT', chapter: '1',
+ verses: [], chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation: 'x', interpretation: '', application: '', crossReferences: [], greekWords: [] }],
+ }],
+ };
+ const result = migrateProject(modern);
+ expect(result.chapters).toHaveLength(1);
+ expect(result.chapters[0].chunks[0].observation).toBe('x');
+ });
+
+ test('returns null for null input', () => {
+ expect(migrateProject(null)).toBeNull();
+ });
+});
+
// ---------------------------------------------------------------------------
// buildExportHtml
// ---------------------------------------------------------------------------
+const baseChunk = {
+ id: 'chunk-1',
+ startVerse: 1,
+ endVerse: 2,
+ observation: 'Key observations.',
+ interpretation: 'Theological meaning.',
+ application: 'Live it out.',
+ crossReferences: ['John 1:1'],
+ greekWords: [
+ {
+ strongNumber: 'G1401',
+ lexeme: 'δοῦλος',
+ transliteration: 'doulos',
+ partOfSpeech: 'Noun',
+ shortDefinition: 'a slave',
+ definitionHtml: '
Part(s) of speech: Noun
A slave or servant.
',
+ },
+ ],
+};
+
const baseProject = {
+ id: 'test-project-1',
title: 'Titus 1 Study',
translation: 'BSB',
- book: 'Titus',
- chapter: '1',
- verses: [
- { number: 1, text: 'Paul, a servant of God.' },
- { number: 2, text: 'In hope of eternal life.' },
- ],
- chunks: [
+ lastEdited: 1700000000000,
+ selectedChunkId: 'chunk-1',
+ chapters: [
{
- id: 'chunk-1',
- startVerse: 1,
- endVerse: 2,
- notes: 'Key observations.',
- greekWords: [
- {
- strongNumber: 'G1401',
- lexeme: 'δοῦλος',
- transliteration: 'doulos',
- partOfSpeech: 'Noun',
- shortDefinition: 'a slave',
- definitionHtml: '
Part(s) of speech: Noun
A slave or servant.
',
- },
+ book: 'Titus',
+ bookAbbrev: 'TIT',
+ chapter: '1',
+ verses: [
+ { number: 1, text: 'Paul, a servant of God.' },
+ { number: 2, text: 'In hope of eternal life.' },
],
+ chunks: [baseChunk],
},
],
};
@@ -305,8 +383,15 @@ describe('buildExportHtml', () => {
expect(html).toContain('In hope of eternal life.');
});
- test('includes study notes', () => {
- expect(buildExportHtml(baseProject)).toContain('Key observations.');
+ test('includes OIA notes', () => {
+ const html = buildExportHtml(baseProject);
+ expect(html).toContain('Key observations.');
+ expect(html).toContain('Theological meaning.');
+ expect(html).toContain('Live it out.');
+ });
+
+ test('includes cross-references', () => {
+ expect(buildExportHtml(baseProject)).toContain('John 1:1');
});
test('includes Greek word data', () => {
@@ -320,7 +405,7 @@ describe('buildExportHtml', () => {
test('formats a single-verse reference without a range dash', () => {
const project = {
...baseProject,
- chunks: [{ ...baseProject.chunks[0], startVerse: 1, endVerse: 1 }],
+ chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, startVerse: 1, endVerse: 1 }] }],
};
const html = buildExportHtml(project);
expect(html).toContain('Titus 1:1');
@@ -331,18 +416,27 @@ describe('buildExportHtml', () => {
expect(buildExportHtml(baseProject)).toContain('Titus 1:1-2');
});
- test('shows "No notes." when chunk notes are empty', () => {
- const project = { ...baseProject, chunks: [{ ...baseProject.chunks[0], notes: '' }] };
- expect(buildExportHtml(project)).toContain('No notes.');
+ test('shows "No observation." when chunk observation is empty', () => {
+ const project = {
+ ...baseProject,
+ chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, observation: '', interpretation: '', application: '' }] }],
+ };
+ expect(buildExportHtml(project)).toContain('No observation.');
});
test('shows "No Greek word notes." when chunk has no Greek words', () => {
- const project = { ...baseProject, chunks: [{ ...baseProject.chunks[0], greekWords: [] }] };
+ const project = {
+ ...baseProject,
+ chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, greekWords: [] }] }],
+ };
expect(buildExportHtml(project)).toContain('No Greek word notes.');
});
test('handles a project with no chunks', () => {
- const project = { ...baseProject, chunks: [] };
+ const project = {
+ ...baseProject,
+ chapters: [{ ...baseProject.chapters[0], chunks: [] }],
+ };
const html = buildExportHtml(project);
expect(html).toContain('');
expect(html).toContain('Titus 1 Study');
@@ -351,12 +445,15 @@ describe('buildExportHtml', () => {
test('only includes verses that fall within the chunk range', () => {
const project = {
...baseProject,
- verses: [
- { number: 1, text: 'Verse one text.' },
- { number: 2, text: 'Verse two text.' },
- { number: 3, text: 'Verse three text.' },
- ],
- chunks: [{ ...baseProject.chunks[0], startVerse: 2, endVerse: 2, greekWords: [] }],
+ chapters: [{
+ ...baseProject.chapters[0],
+ verses: [
+ { number: 1, text: 'Verse one text.' },
+ { number: 2, text: 'Verse two text.' },
+ { number: 3, text: 'Verse three text.' },
+ ],
+ chunks: [{ ...baseChunk, startVerse: 2, endVerse: 2, greekWords: [] }],
+ }],
};
const html = buildExportHtml(project);
expect(html).toContain('Verse two text.');
@@ -407,8 +504,15 @@ describe('buildClaudePrompt', () => {
expect(prompt).toContain('In hope of eternal life.');
});
- test('includes study notes', () => {
- expect(buildClaudePrompt(baseProject)).toContain('Key observations.');
+ test('includes OIA notes', () => {
+ const prompt = buildClaudePrompt(baseProject);
+ expect(prompt).toContain('Key observations.');
+ expect(prompt).toContain('Theological meaning.');
+ expect(prompt).toContain('Live it out.');
+ });
+
+ test('includes cross-references', () => {
+ expect(buildClaudePrompt(baseProject)).toContain('John 1:1');
});
test('includes Greek word data', () => {
@@ -422,10 +526,13 @@ describe('buildClaudePrompt', () => {
test('numbers each chunk sequentially', () => {
const multiChunkProject = {
...baseProject,
- chunks: [
- { ...baseProject.chunks[0], id: 'c1', startVerse: 1, endVerse: 1 },
- { ...baseProject.chunks[0], id: 'c2', startVerse: 2, endVerse: 2, notes: 'Second chunk notes.', greekWords: [] },
- ],
+ chapters: [{
+ ...baseProject.chapters[0],
+ chunks: [
+ { ...baseChunk, id: 'c1', startVerse: 1, endVerse: 1 },
+ { ...baseChunk, id: 'c2', startVerse: 2, endVerse: 2, observation: 'Second chunk notes.', greekWords: [] },
+ ],
+ }],
};
const prompt = buildClaudePrompt(multiChunkProject);
expect(prompt).toContain('CHUNK 1');
@@ -435,7 +542,7 @@ describe('buildClaudePrompt', () => {
test('formats a single-verse reference without a dash', () => {
const project = {
...baseProject,
- chunks: [{ ...baseProject.chunks[0], startVerse: 1, endVerse: 1 }],
+ chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, startVerse: 1, endVerse: 1 }] }],
};
const prompt = buildClaudePrompt(project);
expect(prompt).toContain('Titus 1:1');
@@ -447,25 +554,34 @@ describe('buildClaudePrompt', () => {
expect(prompt).toContain('Titus 1:1–2');
});
- test('shows "No notes." when chunk notes are empty', () => {
- const project = { ...baseProject, chunks: [{ ...baseProject.chunks[0], notes: '' }] };
- expect(buildClaudePrompt(project)).toContain('No notes.');
+ test('shows "No observation." when chunk observation is empty', () => {
+ const project = {
+ ...baseProject,
+ chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, observation: '', interpretation: '', application: '' }] }],
+ };
+ expect(buildClaudePrompt(project)).toContain('No observation.');
});
test('shows "None." when chunk has no Greek words', () => {
- const project = { ...baseProject, chunks: [{ ...baseProject.chunks[0], greekWords: [] }] };
+ const project = {
+ ...baseProject,
+ chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, greekWords: [] }] }],
+ };
expect(buildClaudePrompt(project)).toContain('None.');
});
test('only includes verses within the chunk range', () => {
const project = {
...baseProject,
- verses: [
- { number: 1, text: 'Verse one.' },
- { number: 2, text: 'Verse two.' },
- { number: 3, text: 'Verse three.' },
- ],
- chunks: [{ ...baseProject.chunks[0], startVerse: 2, endVerse: 2, greekWords: [] }],
+ chapters: [{
+ ...baseProject.chapters[0],
+ verses: [
+ { number: 1, text: 'Verse one.' },
+ { number: 2, text: 'Verse two.' },
+ { number: 3, text: 'Verse three.' },
+ ],
+ chunks: [{ ...baseChunk, startVerse: 2, endVerse: 2, greekWords: [] }],
+ }],
};
const prompt = buildClaudePrompt(project);
expect(prompt).toContain('Verse two.');