Add project dashboard, OIA notes, cross-references, and multi-chapter support
- Home page (project list/dashboard): projects stored in localStorage index, Resume/Delete per project, sorted by last-edited date - OIA notes: replace single `notes` field with observation/interpretation/application textareas on each chunk following inductive study method - Cross-references: string tag array per chunk with inline add/remove UI, included in HTML and DOCX exports and Claude prompt - Multi-chapter projects: chapters array on project, chapter tabs in setup view, chapter-grouped sidebar in study view, global chunk navigation across chapters - Migration: migrateChunk/migrateProject exported helpers auto-upgrade old localStorage entries on startup - Tests updated: new baseProject fixture, 11 additional tests (99 total)
This commit is contained in:
+1282
-788
File diff suppressed because it is too large
Load Diff
+25
-2
@@ -62,6 +62,7 @@ afterEach(() => {
|
|||||||
async function loadChapter() {
|
async function loadChapter() {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||||
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
||||||
await screen.findByText(/Scripture & Chunks/i);
|
await screen.findByText(/Scripture & Chunks/i);
|
||||||
return user;
|
return user;
|
||||||
@@ -97,14 +98,33 @@ function findChunkCounter(n, total) {
|
|||||||
// Initial render
|
// Initial render
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
describe('Initial render', () => {
|
describe('Initial render', () => {
|
||||||
test('shows the project setup form', () => {
|
test('shows the home page with "My Studies" heading', () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
expect(screen.getByText('My Studies')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows "No projects yet" when storage is empty', () => {
|
||||||
|
render(<App />);
|
||||||
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows "New Project" button on home page', () => {
|
||||||
|
render(<App />);
|
||||||
|
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(<App />);
|
||||||
|
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||||
expect(screen.getByText('Project Setup')).toBeInTheDocument();
|
expect(screen.getByText('Project Setup')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /load chapter/i })).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(<App />);
|
render(<App />);
|
||||||
|
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||||
expect(screen.getByText('Translation')).toBeInTheDocument();
|
expect(screen.getByText('Translation')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Book')).toBeInTheDocument();
|
expect(screen.getByText('Book')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Chapter')).toBeInTheDocument();
|
expect(screen.getByText('Chapter')).toBeInTheDocument();
|
||||||
@@ -139,6 +159,7 @@ describe('Loading a chapter', () => {
|
|||||||
}));
|
}));
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||||
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
||||||
await screen.findByText(/unable to 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: [] } } }));
|
vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } }));
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||||
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
||||||
await screen.findByText(/invalid bible data/i);
|
await screen.findByText(/invalid bible data/i);
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+167
-51
@@ -9,6 +9,8 @@ import {
|
|||||||
buildExportHtml,
|
buildExportHtml,
|
||||||
buildClaudePrompt,
|
buildClaudePrompt,
|
||||||
createParagraphsFromText,
|
createParagraphsFromText,
|
||||||
|
migrateChunk,
|
||||||
|
migrateProject,
|
||||||
} from './App.jsx';
|
} 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
|
// 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: '<p>Part(s) of speech: Noun</p><p>A slave or servant.</p>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const baseProject = {
|
const baseProject = {
|
||||||
|
id: 'test-project-1',
|
||||||
title: 'Titus 1 Study',
|
title: 'Titus 1 Study',
|
||||||
translation: 'BSB',
|
translation: 'BSB',
|
||||||
book: 'Titus',
|
lastEdited: 1700000000000,
|
||||||
chapter: '1',
|
selectedChunkId: 'chunk-1',
|
||||||
verses: [
|
chapters: [
|
||||||
{ number: 1, text: 'Paul, a servant of God.' },
|
|
||||||
{ number: 2, text: 'In hope of eternal life.' },
|
|
||||||
],
|
|
||||||
chunks: [
|
|
||||||
{
|
{
|
||||||
id: 'chunk-1',
|
book: 'Titus',
|
||||||
startVerse: 1,
|
bookAbbrev: 'TIT',
|
||||||
endVerse: 2,
|
chapter: '1',
|
||||||
notes: 'Key observations.',
|
verses: [
|
||||||
greekWords: [
|
{ number: 1, text: 'Paul, a servant of God.' },
|
||||||
{
|
{ number: 2, text: 'In hope of eternal life.' },
|
||||||
strongNumber: 'G1401',
|
|
||||||
lexeme: 'δοῦλος',
|
|
||||||
transliteration: 'doulos',
|
|
||||||
partOfSpeech: 'Noun',
|
|
||||||
shortDefinition: 'a slave',
|
|
||||||
definitionHtml: '<p>Part(s) of speech: Noun</p><p>A slave or servant.</p>',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
|
chunks: [baseChunk],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -305,8 +383,15 @@ describe('buildExportHtml', () => {
|
|||||||
expect(html).toContain('In hope of eternal life.');
|
expect(html).toContain('In hope of eternal life.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('includes study notes', () => {
|
test('includes OIA notes', () => {
|
||||||
expect(buildExportHtml(baseProject)).toContain('Key observations.');
|
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', () => {
|
test('includes Greek word data', () => {
|
||||||
@@ -320,7 +405,7 @@ describe('buildExportHtml', () => {
|
|||||||
test('formats a single-verse reference without a range dash', () => {
|
test('formats a single-verse reference without a range dash', () => {
|
||||||
const project = {
|
const project = {
|
||||||
...baseProject,
|
...baseProject,
|
||||||
chunks: [{ ...baseProject.chunks[0], startVerse: 1, endVerse: 1 }],
|
chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, startVerse: 1, endVerse: 1 }] }],
|
||||||
};
|
};
|
||||||
const html = buildExportHtml(project);
|
const html = buildExportHtml(project);
|
||||||
expect(html).toContain('Titus 1:1');
|
expect(html).toContain('Titus 1:1');
|
||||||
@@ -331,18 +416,27 @@ describe('buildExportHtml', () => {
|
|||||||
expect(buildExportHtml(baseProject)).toContain('Titus 1:1-2');
|
expect(buildExportHtml(baseProject)).toContain('Titus 1:1-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows "No notes." when chunk notes are empty', () => {
|
test('shows "No observation." when chunk observation is empty', () => {
|
||||||
const project = { ...baseProject, chunks: [{ ...baseProject.chunks[0], notes: '' }] };
|
const project = {
|
||||||
expect(buildExportHtml(project)).toContain('No notes.');
|
...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', () => {
|
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.');
|
expect(buildExportHtml(project)).toContain('No Greek word notes.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles a project with no chunks', () => {
|
test('handles a project with no chunks', () => {
|
||||||
const project = { ...baseProject, chunks: [] };
|
const project = {
|
||||||
|
...baseProject,
|
||||||
|
chapters: [{ ...baseProject.chapters[0], chunks: [] }],
|
||||||
|
};
|
||||||
const html = buildExportHtml(project);
|
const html = buildExportHtml(project);
|
||||||
expect(html).toContain('<!DOCTYPE html>');
|
expect(html).toContain('<!DOCTYPE html>');
|
||||||
expect(html).toContain('Titus 1 Study');
|
expect(html).toContain('Titus 1 Study');
|
||||||
@@ -351,12 +445,15 @@ describe('buildExportHtml', () => {
|
|||||||
test('only includes verses that fall within the chunk range', () => {
|
test('only includes verses that fall within the chunk range', () => {
|
||||||
const project = {
|
const project = {
|
||||||
...baseProject,
|
...baseProject,
|
||||||
verses: [
|
chapters: [{
|
||||||
{ number: 1, text: 'Verse one text.' },
|
...baseProject.chapters[0],
|
||||||
{ number: 2, text: 'Verse two text.' },
|
verses: [
|
||||||
{ number: 3, text: 'Verse three text.' },
|
{ number: 1, text: 'Verse one text.' },
|
||||||
],
|
{ number: 2, text: 'Verse two text.' },
|
||||||
chunks: [{ ...baseProject.chunks[0], startVerse: 2, endVerse: 2, greekWords: [] }],
|
{ number: 3, text: 'Verse three text.' },
|
||||||
|
],
|
||||||
|
chunks: [{ ...baseChunk, startVerse: 2, endVerse: 2, greekWords: [] }],
|
||||||
|
}],
|
||||||
};
|
};
|
||||||
const html = buildExportHtml(project);
|
const html = buildExportHtml(project);
|
||||||
expect(html).toContain('Verse two text.');
|
expect(html).toContain('Verse two text.');
|
||||||
@@ -407,8 +504,15 @@ describe('buildClaudePrompt', () => {
|
|||||||
expect(prompt).toContain('In hope of eternal life.');
|
expect(prompt).toContain('In hope of eternal life.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('includes study notes', () => {
|
test('includes OIA notes', () => {
|
||||||
expect(buildClaudePrompt(baseProject)).toContain('Key observations.');
|
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', () => {
|
test('includes Greek word data', () => {
|
||||||
@@ -422,10 +526,13 @@ describe('buildClaudePrompt', () => {
|
|||||||
test('numbers each chunk sequentially', () => {
|
test('numbers each chunk sequentially', () => {
|
||||||
const multiChunkProject = {
|
const multiChunkProject = {
|
||||||
...baseProject,
|
...baseProject,
|
||||||
chunks: [
|
chapters: [{
|
||||||
{ ...baseProject.chunks[0], id: 'c1', startVerse: 1, endVerse: 1 },
|
...baseProject.chapters[0],
|
||||||
{ ...baseProject.chunks[0], id: 'c2', startVerse: 2, endVerse: 2, notes: 'Second chunk notes.', greekWords: [] },
|
chunks: [
|
||||||
],
|
{ ...baseChunk, id: 'c1', startVerse: 1, endVerse: 1 },
|
||||||
|
{ ...baseChunk, id: 'c2', startVerse: 2, endVerse: 2, observation: 'Second chunk notes.', greekWords: [] },
|
||||||
|
],
|
||||||
|
}],
|
||||||
};
|
};
|
||||||
const prompt = buildClaudePrompt(multiChunkProject);
|
const prompt = buildClaudePrompt(multiChunkProject);
|
||||||
expect(prompt).toContain('CHUNK 1');
|
expect(prompt).toContain('CHUNK 1');
|
||||||
@@ -435,7 +542,7 @@ describe('buildClaudePrompt', () => {
|
|||||||
test('formats a single-verse reference without a dash', () => {
|
test('formats a single-verse reference without a dash', () => {
|
||||||
const project = {
|
const project = {
|
||||||
...baseProject,
|
...baseProject,
|
||||||
chunks: [{ ...baseProject.chunks[0], startVerse: 1, endVerse: 1 }],
|
chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, startVerse: 1, endVerse: 1 }] }],
|
||||||
};
|
};
|
||||||
const prompt = buildClaudePrompt(project);
|
const prompt = buildClaudePrompt(project);
|
||||||
expect(prompt).toContain('Titus 1:1');
|
expect(prompt).toContain('Titus 1:1');
|
||||||
@@ -447,25 +554,34 @@ describe('buildClaudePrompt', () => {
|
|||||||
expect(prompt).toContain('Titus 1:1–2');
|
expect(prompt).toContain('Titus 1:1–2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows "No notes." when chunk notes are empty', () => {
|
test('shows "No observation." when chunk observation is empty', () => {
|
||||||
const project = { ...baseProject, chunks: [{ ...baseProject.chunks[0], notes: '' }] };
|
const project = {
|
||||||
expect(buildClaudePrompt(project)).toContain('No notes.');
|
...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', () => {
|
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.');
|
expect(buildClaudePrompt(project)).toContain('None.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('only includes verses within the chunk range', () => {
|
test('only includes verses within the chunk range', () => {
|
||||||
const project = {
|
const project = {
|
||||||
...baseProject,
|
...baseProject,
|
||||||
verses: [
|
chapters: [{
|
||||||
{ number: 1, text: 'Verse one.' },
|
...baseProject.chapters[0],
|
||||||
{ number: 2, text: 'Verse two.' },
|
verses: [
|
||||||
{ number: 3, text: 'Verse three.' },
|
{ number: 1, text: 'Verse one.' },
|
||||||
],
|
{ number: 2, text: 'Verse two.' },
|
||||||
chunks: [{ ...baseProject.chunks[0], startVerse: 2, endVerse: 2, greekWords: [] }],
|
{ number: 3, text: 'Verse three.' },
|
||||||
|
],
|
||||||
|
chunks: [{ ...baseChunk, startVerse: 2, endVerse: 2, greekWords: [] }],
|
||||||
|
}],
|
||||||
};
|
};
|
||||||
const prompt = buildClaudePrompt(project);
|
const prompt = buildClaudePrompt(project);
|
||||||
expect(prompt).toContain('Verse two.');
|
expect(prompt).toContain('Verse two.');
|
||||||
|
|||||||
Reference in New Issue
Block a user