Move chunk builder into SetupPage via useChunkBuilder

- The setup form, active chapter tab, verse range selection, typed/bulk/
  cross-chapter chunk creation, reorder, and delete move out of App.
- Setup now opens on the chapter holding the chunk last studied (e.g.
  after Back to Chunks), and the Add Chapter form suggests the chapter
  after the project's last one instead of repeating the current one.
- openNewProject/goHome/resume/import no longer reset setup state by hand;
  SetupPage starts fresh each time it opens.
- Tests: add-chapter default, Back to Chunks chapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Opus 5.5 committed 2026-09-22 15:01:53 -04:00
1 parent bf02e071fa
commit 0f1a9c3d5b
6 files changed
+555 -518

No files matched your search

+1 -510
View File
@@ -9,10 +9,7 @@ import { useSharedView } from './hooks/useSharedView.js';
import HomePage from './pages/HomePage.jsx';
import {
bookOptions,
makeId,
buildExportHtml,
parseBibleChapter,
loadProjectIndex,
switchStorageUser,
} from './utils/bibleUtils.jsx';
@@ -45,31 +42,11 @@ const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
// ---------------------------------------------------------------------------
const App = () => {
const [setup, setSetup] = useState({
translation: 'BSB',
book: 'Titus',
bookAbbrev: 'TIT',
chapter: '1',
title: 'Titus 1 Study',
});
const [titleEdited, setTitleEdited] = useState(false);
const [project, setProject] = useState(null);
// 'home' | 'setup' | 'study' | 'settings'
const [currentPage, setCurrentPage] = useState('home');
const [projectIndex, setProjectIndex] = useState([]);
const [activeChapterIndex, setActiveChapterIndex] = useState(0);
const [showAddChapterForm, setShowAddChapterForm] = useState(false);
const [saveStatus, setSaveStatus] = useState('');
const [rangeStart, setRangeStart] = useState(null);
const [rangeEnd, setRangeEnd] = useState(null);
const [loadingChapter, setLoadingChapter] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [typedChunkStart, setTypedChunkStart] = useState('');
const [typedChunkEnd, setTypedChunkEnd] = useState('');
const [typedChunkNextEnd, setTypedChunkNextEnd] = useState('');
const [clickedSpanNextEnd, setClickedSpanNextEnd] = useState('');
const [typedChunkBulk, setTypedChunkBulk] = useState('');
const saveTimerRef = useRef(null);
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
const reader = useBibleReader(currentPage);
@@ -95,9 +72,7 @@ const App = () => {
setProject,
setProjectIndex,
setCurrentPage,
setActiveChapterIndex,
});
const [verseSearch, setVerseSearch] = useState('');
const openBibleReader = () => {
setCurrentPage('reader');
};
@@ -110,17 +85,7 @@ const App = () => {
setProjectIndex(loadProjectIndex());
}, []);
useEffect(() => {
if (!titleEdited) {
setSetup((current) => ({
...current,
title: `${current.book} ${current.chapter} Study`,
}));
}
}, [setup.book, setup.chapter, titleEdited]);
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
const activeChapter = project?.chapters[activeChapterIndex] ?? null;
// Autosave
useEffect(() => {
if (!project) return;
@@ -147,412 +112,10 @@ const App = () => {
};
}, [project]);
// ---------------------------------------------------------------------------
// Setup helpers
// ---------------------------------------------------------------------------
const handleSetupField = (field, value) => {
if (field === 'book') {
const selected = bookOptions.find((book) => book.abbrev === value) ?? bookOptions[0];
setSetup((current) => ({
...current,
book: selected.name,
bookAbbrev: selected.abbrev,
}));
} else {
setSetup((current) => ({ ...current, [field]: value }));
}
};
const handleLoadChapter = async () => {
if (!setup.bookAbbrev || !setup.chapter) {
setErrorMessage('Please choose a book and chapter.');
return;
}
setErrorMessage('');
setLoadingChapter(true);
try {
const response = await fetch(
`https://bible.helloao.org/api/${setup.translation}/${setup.bookAbbrev}/${setup.chapter}.json`,
);
if (!response.ok) throw new Error('Unable to load chapter.');
const data = await response.json();
const verses = parseBibleChapter(data);
if (!data || !Array.isArray(verses) || verses.length === 0) {
throw new Error('Invalid Bible data returned.');
}
if (project) {
// Adding a chapter to an existing project
const alreadyExists = project.chapters.some(
(ch) => ch.bookAbbrev === setup.bookAbbrev && ch.chapter === setup.chapter,
);
if (alreadyExists) {
setErrorMessage('That chapter is already in this project.');
setLoadingChapter(false);
return;
}
const newChapter = {
book: setup.book,
bookAbbrev: setup.bookAbbrev,
chapter: setup.chapter,
verses,
chunks: [],
};
updateProject((current) => {
const updatedChapters = [...current.chapters, newChapter];
return { ...current, chapters: updatedChapters };
});
setActiveChapterIndex(project.chapters.length);
setShowAddChapterForm(false);
setErrorMessage('');
} else {
// Creating a brand-new project
const newProject = {
id: makeId(),
title: setup.title,
translation: setup.translation,
lastEdited: Date.now(),
selectedChunkId: null,
chapters: [
{
book: setup.book,
bookAbbrev: setup.bookAbbrev,
chapter: setup.chapter,
verses,
chunks: [],
},
],
};
setProject(newProject);
setActiveChapterIndex(0);
setCurrentPage('setup');
}
} catch (error) {
setErrorMessage(error.message || 'Failed to load chapter.');
} finally {
setLoadingChapter(false);
}
};
const updateProject = (updater) => {
setProject((current) => (current ? updater(current) : current));
};
const beginStudying = () => {
if (!project || allChunks.length === 0) return;
updateProject((current) => ({
...current,
selectedChunkId: current.selectedChunkId || allChunks[0]?.id || null,
}));
setCurrentPage('study');
};
// ---------------------------------------------------------------------------
// Chunk CRUD (operates on activeChapter in setup view, on selectedChunkChapter in study)
// ---------------------------------------------------------------------------
const addChunkToChapter = (chapterIndex, start, end, spilloverEndVerse = null) => {
updateProject((current) => {
const chapters = current.chapters.map((ch, idx) => {
if (idx !== chapterIndex) return ch;
const overlap = ch.chunks.find(
(c) => c.startVerse === start
&& c.endVerse === end
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
);
if (overlap) return ch;
const newChunk = {
id: makeId(),
startVerse: start,
endVerse: end,
spilloverEndVerse,
observation: '',
interpretation: '',
application: '',
crossReferences: [],
greekWords: [],
generalNotes: '',
episodeNumber: '',
episodeTitle: '',
finalScript: '',
tags: [],
};
return { ...ch, chunks: [...ch.chunks, newChunk] };
});
// Find the newly added chunk id
const newChunk = chapters[chapterIndex].chunks.at(-1);
return { ...current, chapters, selectedChunkId: newChunk?.id ?? current.selectedChunkId };
});
};
const addChunk = (start, end, spilloverEndVerse = null) => {
if (!project) return;
const chapter = activeChapter;
if (!chapter) return;
const overlap = chapter.chunks.find(
(c) => c.startVerse === start
&& c.endVerse === end
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
);
if (overlap) {
setStatusMessage('That chunk already exists.');
window.setTimeout(() => setStatusMessage(''), 1800);
return;
}
addChunkToChapter(activeChapterIndex, start, end, spilloverEndVerse);
};
const addChunkRanges = (ranges) => {
if (!project || !activeChapter || ranges.length === 0) {
return { addedCount: 0, skippedCount: 0 };
}
let addedCount = 0;
let skippedCount = 0;
let lastAddedChunkId = null;
updateProject((current) => {
const chapters = current.chapters.map((ch, idx) => {
if (idx !== activeChapterIndex) return ch;
const existing = new Set(
ch.chunks.map((c) => `${c.startVerse}-${c.endVerse}-${c.spilloverEndVerse ?? ''}`),
);
const newChunks = [];
ranges.forEach(({ start, end, spilloverEndVerse = null }) => {
const key = `${start}-${end}-${spilloverEndVerse ?? ''}`;
if (existing.has(key)) {
skippedCount += 1;
return;
}
const newChunk = {
id: makeId(),
startVerse: start,
endVerse: end,
spilloverEndVerse,
observation: '',
interpretation: '',
application: '',
crossReferences: [],
greekWords: [],
tags: [],
};
existing.add(key);
newChunks.push(newChunk);
addedCount += 1;
lastAddedChunkId = newChunk.id;
});
return newChunks.length > 0
? { ...ch, chunks: [...ch.chunks, ...newChunks] }
: ch;
});
return {
...current,
chapters,
selectedChunkId: lastAddedChunkId ?? current.selectedChunkId,
};
});
return { addedCount, skippedCount };
};
const addTypedChunk = () => {
if (!activeChapter) return;
const start = Number.parseInt(typedChunkStart, 10);
const end = Number.parseInt(typedChunkEnd, 10);
const nextEndRaw = typedChunkNextEnd.trim();
const nextEnd = nextEndRaw ? Number.parseInt(nextEndRaw, 10) : null;
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
if (!Number.isInteger(start) || !Number.isInteger(end)) {
setStatusMessage('Type a valid start and end verse.');
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
if (start < 1 || end < 1 || start > maxVerse || end > maxVerse) {
setStatusMessage(`Verse range must be between 1 and ${maxVerse}.`);
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
if (start > end) {
setStatusMessage('Start verse must be less than or equal to end verse.');
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
if (nextEndRaw) {
if (!nextChapter) {
setStatusMessage('Add the next chapter first to span chunks across chapters.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
setStatusMessage('Chapter spanning only works into the next chapter of the same book.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
if (!Number.isInteger(nextEnd) || nextEnd < 1 || nextEnd > nextMaxVerse) {
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
if (end !== maxVerse) {
setStatusMessage('To span chapters, set End to the last verse of this chapter.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
}
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse: nextEnd }]);
if (addedCount === 0) {
setStatusMessage('That chunk already exists.');
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
setTypedChunkStart('');
setTypedChunkEnd('');
setTypedChunkNextEnd('');
};
const addBulkTypedChunks = () => {
if (!activeChapter) return;
const raw = typedChunkBulk.trim();
if (!raw) {
setStatusMessage('Type chunk ranges first. Example: 1-6, 7-12');
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
const parts = raw
.split(/[\n,;]+/)
.map((part) => part.trim())
.filter(Boolean);
const validRanges = [];
let invalidCount = 0;
parts.forEach((part) => {
const single = /^(\d+)$/.exec(part);
if (single) {
const v = Number.parseInt(single[1], 10);
if (v >= 1 && v <= maxVerse) {
validRanges.push({ start: v, end: v });
} else {
invalidCount += 1;
}
return;
}
const range = /^(\d+)\s*-\s*(\d+)(?:\s*:\s*(\d+))?$/.exec(part);
if (!range) {
invalidCount += 1;
return;
}
const start = Number.parseInt(range[1], 10);
const end = Number.parseInt(range[2], 10);
const spilloverEndVerse = range[3] ? Number.parseInt(range[3], 10) : null;
if (start < 1 || end < 1 || start > end || start > maxVerse || end > maxVerse) {
invalidCount += 1;
return;
}
if (spilloverEndVerse !== null) {
if (!nextChapter
|| nextChapter.bookAbbrev !== activeChapter.bookAbbrev
|| spilloverEndVerse < 1
|| spilloverEndVerse > nextMaxVerse
|| end !== maxVerse) {
invalidCount += 1;
return;
}
}
validRanges.push({ start, end, spilloverEndVerse });
});
if (validRanges.length === 0) {
setStatusMessage(`No valid ranges found. Use 1-${maxVerse} and format like 1-6, 7-12.`);
window.setTimeout(() => setStatusMessage(''), 2600);
return;
}
const { addedCount, skippedCount } = addChunkRanges(validRanges);
const notes = [];
if (addedCount > 0) notes.push(`Added ${addedCount} chunk${addedCount > 1 ? 's' : ''}`);
if (skippedCount > 0) notes.push(`skipped ${skippedCount} duplicate${skippedCount > 1 ? 's' : ''}`);
if (invalidCount > 0) notes.push(`ignored ${invalidCount} invalid`);
setStatusMessage(notes.join(' • '));
window.setTimeout(() => setStatusMessage(''), 2800);
if (addedCount > 0) {
setTypedChunkBulk('');
}
};
const addClickSpanChunk = () => {
if (!activeChapter || rangeStart === null) {
setStatusMessage('Click a start verse first, then set next chapter end.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
if (!nextChapter) {
setStatusMessage('Add the next chapter first to span into it.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
setStatusMessage('Click-based spanning only works into the next chapter of the same book.');
window.setTimeout(() => setStatusMessage(''), 2600);
return;
}
const nextMaxVerse = nextChapter.verses.at(-1)?.number ?? 0;
const spilloverEndVerse = Number.parseInt(clickedSpanNextEnd, 10);
if (!Number.isInteger(spilloverEndVerse) || spilloverEndVerse < 1 || spilloverEndVerse > nextMaxVerse) {
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
const start = rangeStart;
const end = activeChapter.verses.at(-1)?.number ?? start;
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse }]);
if (addedCount === 0) {
setStatusMessage('That cross-chapter chunk already exists.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
setClickedSpanNextEnd('');
setRangeStart(null);
setRangeEnd(null);
setStatusMessage('Cross-chapter chunk added.');
window.setTimeout(() => setStatusMessage(''), 1800);
};
const handleVerseClick = (verseNumber, event) => {
if (event.shiftKey && rangeStart !== null) {
const start = Math.min(rangeStart, verseNumber);
const end = Math.max(rangeStart, verseNumber);
addChunk(start, end);
setRangeStart(null);
setRangeEnd(null);
return;
}
setRangeStart(verseNumber);
setRangeEnd(verseNumber);
};
const updateChunk = (chunkId, patch) => {
updateProject((current) => ({
...current,
@@ -563,55 +126,12 @@ const App = () => {
}));
};
const moveChunk = (chunkId, direction) => {
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => {
const index = ch.chunks.findIndex((c) => c.id === chunkId);
if (index < 0) return ch;
const nextIndex = index + direction;
if (nextIndex < 0 || nextIndex >= ch.chunks.length) return ch;
const updated = [...ch.chunks];
const [removed] = updated.splice(index, 1);
updated.splice(nextIndex, 0, removed);
return { ...ch, chunks: updated };
}),
}));
};
const deleteChunk = (chunkId) => {
updateProject((current) => {
const newAllChunks = current.chapters
.flatMap((ch) => ch.chunks)
.filter((c) => c.id !== chunkId);
const nextSelected =
current.selectedChunkId === chunkId
? newAllChunks[0]?.id ?? null
: current.selectedChunkId;
return {
...current,
selectedChunkId: nextSelected,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.filter((c) => c.id !== chunkId),
})),
};
});
};
// ---------------------------------------------------------------------------
// Home page helpers
// ---------------------------------------------------------------------------
const openNewProject = () => {
setProject(null);
setActiveChapterIndex(0);
setShowAddChapterForm(false);
setRangeStart(null);
setRangeEnd(null);
setErrorMessage('');
setStatusMessage('');
setTitleEdited(false);
setCurrentPage('setup');
};
@@ -624,10 +144,6 @@ const App = () => {
setProjectIndex(loadProjectIndex());
setCurrentPage('home');
setProject(null);
setRangeStart(null);
setRangeEnd(null);
setErrorMessage('');
setStatusMessage('');
};
// Manual retry after a failed background sync (shown in the project header).
@@ -668,34 +184,9 @@ const App = () => {
...audio,
// Reader
...reader,
// Setup
// Setup (chunk-builder state lives in SetupPage via useChunkBuilder)
project, setProject,
setup,
titleEdited, setTitleEdited,
activeChapterIndex, setActiveChapterIndex,
activeChapter,
showAddChapterForm, setShowAddChapterForm,
rangeStart, setRangeStart,
rangeEnd, setRangeEnd,
verseSearch, setVerseSearch,
typedChunkStart, setTypedChunkStart,
typedChunkEnd, setTypedChunkEnd,
typedChunkNextEnd, setTypedChunkNextEnd,
typedChunkBulk, setTypedChunkBulk,
clickedSpanNextEnd, setClickedSpanNextEnd,
loadingChapter,
errorMessage,
statusMessage,
allChunks,
handleSetupField,
handleLoadChapter,
beginStudying,
handleVerseClick,
addTypedChunk,
addBulkTypedChunks,
addClickSpanChunk,
moveChunk,
deleteChunk,
updateProject,
// Study (editor state lives in StudyPage and its hooks)
updateChunk,