diff --git a/src/App.jsx b/src/App.jsx index 8658a7c..f3b2197 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useEffect, useRef, useState } from 'react'; +import { getPlanSchedule } from './utils/readingPlans.js'; import { AppContext, useApp } from './context/AppContext.js'; import { useAudio } from './hooks/useAudio.js'; import { useAuth } from './hooks/useAuth.js'; @@ -38,9 +39,15 @@ const App = () => { const saveTimerRef = useRef(null); const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' const [studyBuildBusy, setStudyBuildBusy] = useState(false); - const reader = useBibleReader(currentPage); - const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation); const readingPlans = useReadingPlans(); + // The plan day currently open in the Reader ({ progressId, dayIndex }) — set when + // you read from a plan, cleared when you leave the Reader. While set, the Reader + // uses that plan's own translation. + const [readerPlan, setReaderPlan] = useState(null); + const readerPlanProgress = readerPlan ? readingPlans.planProgress.find((p) => p.id === readerPlan.progressId) ?? null : null; + const reader = useBibleReader(currentPage, readerPlanProgress?.translation ?? null); + const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation); + useEffect(() => { if (currentPage !== 'reader') setReaderPlan(null); }, [currentPage]); const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({ onSignIn: (user) => { setProjectIndex(switchStorageUser(user.id)); @@ -120,12 +127,37 @@ const App = () => { const openReadingPlans = () => setCurrentPage('reading-plans'); - // Jumps straight into a specific plan day's first chapter and opens the reader. + // Jumps straight into a specific chapter and opens the reader (no plan context). const readReadingPlanDay = (bookAbbrev, chapter) => { reader.jumpToReaderVerse(bookAbbrev, chapter, 1); setCurrentPage('reader'); }; + // Opens the Reader inside a plan: on `ref` (or the day's first unread chapter), + // in the plan's own translation, with the plan strip and end-of-day card active. + const openPlanReading = (progressId, dayIndex, ref) => { + const progress = readingPlans.planProgress.find((p) => p.id === progressId); + if (!progress) return; + const refs = getPlanSchedule(progress.planId)[dayIndex] ?? []; + const done = progress.completedDays.includes(dayIndex); + const read = new Set(done ? [] : progress.chaptersRead?.[dayIndex] ?? []); + const target = ref ?? refs.find((r) => !read.has(`${r.book}.${r.chapter}`)) ?? refs[0]; + setReaderPlan({ progressId, dayIndex }); + if (target) { + reader.setReaderBookAbbrev(target.book); + reader.setReaderChapter(target.chapter); + reader.setReaderSelectedVerse(null); + reader.setReaderCrossRefs(null); + } + setCurrentPage('reader'); + }; + + // Changing the translation while a plan is open changes that plan's translation. + const setReaderTranslation = (id) => { + if (readerPlan) readingPlans.setPlanTranslation(readerPlan.progressId, id); + else reader.setReaderTranslation(id); + }; + // Starts book-mode audio playback at a specific plan day's chapter, then // shows the Home audio player. Uses handlePlayBookAudioFrom (not the reader // player) so this works even without opening the reader first. @@ -233,6 +265,10 @@ const App = () => { ...audio, // Reader ...reader, + readerPlan, setReaderPlan, + readerPlanActive: !!readerPlanProgress, + openPlanReading, + setReaderTranslation, // Setup (chunk-builder state lives in SetupPage via useChunkBuilder) project, setProject, allChunks, diff --git a/src/App.test.jsx b/src/App.test.jsx index 800d498..03471cc 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -580,15 +580,46 @@ describe('Home page', () => { expect(await screen.findByDisplayValue('Title')).toBeInTheDocument(); }); + test('Continue reading opens the Reader inside the plan, in the plan\'s own translation', async () => { + seedProject(); + localStorage.setItem('readingPlanProgress', JSON.stringify({ + progress: [{ id: 'rp1', planId: 'gospels-30', startedAt: Date.now(), completedDays: [], chaptersRead: {}, completedAt: null, lastActivityAt: Date.now(), translation: 'ESV' }], + customPlans: [], activity: [], + })); + const esvChapter = { translation: 'ESV', verses: [{ number: 1, text: 'In the beginning was the Word.' }], copyright: '' }; + const fetchMock = vi.fn((url) => { + if (String(url).startsWith('/api/bible/ESV/')) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(esvChapter) }); + return buildFetchMock()(url); + }); + vi.stubGlobal('fetch', fetchMock); + const user = userEvent.setup(); + await renderApp(); + await user.click(screen.getByRole('button', { name: /reading plans/i })); + await user.click(await screen.findByRole('button', { name: /continue reading/i })); + // The plan strip is up, on the plan's translation, and the chapter came from the ESV route + expect(await screen.findByText(/In the beginning was the Word/)).toBeInTheDocument(); + expect(screen.getAllByText(/The Gospels in 30 Days · Day 1/).length).toBeGreaterThan(0); + expect(screen.getByText('ESV')).toBeInTheDocument(); // plan strip shows the plan's translation + expect(fetchMock.mock.calls.some(([u]) => String(u).startsWith('/api/bible/ESV/MAT/1'))).toBe(true); + // Leaving the reader ends the plan context: the reader's own translation applies again + await user.click(screen.getByRole('button', { name: /back to studies/i })); + await user.click(screen.getByRole('button', { name: /read bible/i })); + expect(await screen.findByText(/Matthew 1 · BSB/)).toBeInTheDocument(); + }); + test('a reading plan can be started from the Reading Plans page', async () => { seedProject(); // the nav button shows once there's at least one project const user = userEvent.setup(); await renderApp(); await user.click(screen.getByRole('button', { name: /reading plans/i })); - await user.click((await screen.findAllByRole('button', { name: /^start plan$/i }))[0]); + // Tap a plan in the catalog, pick a translation for it, and start it. + await user.click(await screen.findByRole('button', { name: /The Gospels in 30 Days/ })); + await user.selectOptions(screen.getByRole('combobox'), 'ESV'); + await user.click(screen.getByRole('button', { name: /^start plan$/i })); await waitFor(() => { const stored = JSON.parse(localStorage.getItem('readingPlanProgress')); - expect(stored?.progress?.[0]?.planId).toBeTruthy(); + expect(stored?.progress?.[0]?.planId).toBe('gospels-30'); + expect(stored?.progress?.[0]?.translation).toBe('ESV'); }); }); }); diff --git a/src/components/plans/CustomPlanBuilder.jsx b/src/components/plans/CustomPlanBuilder.jsx new file mode 100644 index 0000000..58c8612 --- /dev/null +++ b/src/components/plans/CustomPlanBuilder.jsx @@ -0,0 +1,270 @@ +// Form for creating or editing a custom reading plan (title, days, cover photo, +// books and chapter ranges). Themed with the reader's --r-* colour variables. +import { useRef, useState } from 'react'; +import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx'; +import { + bookChapterCount, ALL_BOOKS, OT_BOOKS, NT_BOOKS, GOSPELS, PENTATEUCH, WISDOM_BOOKS, +} from '../../utils/readingPlans.js'; + +// Downscales a picked image file to a reasonable cover-photo size and returns +// a data URL — no server upload endpoint needed, it's stored right on the plan. +export function resizeImageToDataUrl(file, maxWidth = 960, quality = 0.82) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('Could not read file.')); + reader.onload = () => { + const img = new Image(); + img.onerror = () => reject(new Error('Could not decode image.')); + img.onload = () => { + const scale = Math.min(1, maxWidth / img.width); + const canvas = document.createElement('canvas'); + canvas.width = Math.round(img.width * scale); + canvas.height = Math.round(img.height * scale); + canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height); + resolve(canvas.toDataURL('image/jpeg', quality)); + }; + img.src = reader.result; + }; + reader.readAsDataURL(file); + }); +} + +const BOOK_PRESETS = [ + ['All', ALL_BOOKS], + ['None', []], + ['Old Testament', OT_BOOKS], + ['New Testament', NT_BOOKS], + ['Gospels', GOSPELS], + ['Pentateuch', PENTATEUCH], + ['Psalms', ['PSA']], + ['Proverbs', ['PRO']], + ['Wisdom', WISDOM_BOOKS], +]; + +export default function CustomPlanBuilder({ initialValues, onSubmit, onCancel }) { + const [title, setTitle] = useState(initialValues?.title ?? ''); + const [description, setDescription] = useState(initialValues?.description ?? ''); + const [days, setDays] = useState(initialValues?.days ?? 30); + const [books, setBooks] = useState(initialValues?.books ?? []); + const [chapterRanges, setChapterRanges] = useState(initialValues?.chapterRanges ?? {}); + const [image, setImage] = useState(initialValues?.image ?? null); + const [imageError, setImageError] = useState(''); + const fileInputRef = useRef(null); + const isEditing = !!initialValues; + + const toggleBook = (abbrev) => { + const isOn = books.includes(abbrev); + setBooks((prev) => (isOn ? prev.filter((b) => b !== abbrev) : [...prev, abbrev])); + if (isOn) { + setChapterRanges((prev) => { + if (!(abbrev in prev)) return prev; + const next = { ...prev }; + delete next[abbrev]; + return next; + }); + } + }; + + const setChapterRange = (abbrev, key, value) => { + const total = CHAPTER_COUNTS[abbrev] ?? 1; + const clamped = Math.min(total, Math.max(1, Math.round(value) || 1)); + setChapterRanges((prev) => ({ + ...prev, + [abbrev]: { start: prev[abbrev]?.start ?? 1, end: prev[abbrev]?.end ?? total, [key]: clamped }, + })); + }; + + const applyPreset = (list) => { + setBooks(list); + setChapterRanges({}); + }; + + const pickImage = async (e) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + try { + setImageError(''); + setImage(await resizeImageToDataUrl(file)); + } catch { + setImageError("Couldn't use that image."); + } + }; + + const totalChapters = books.reduce((sum, b) => sum + bookChapterCount(b, chapterRanges), 0); + const canCreate = title.trim().length > 0 && totalChapters > 0 && days >= 1; + + return ( +
+

{isEditing ? 'Edit reading plan' : 'New reading plan'}

+
+ + +
+