Reading plans: integrated into the Reader, redesigned, with per-plan translation
Build and Push Docker Image / build-and-push (push) Failing after 19s
Build and Push Docker Image / build-and-push (push) Failing after 19s
Reader: plan strip with a dot per chapter, plan sheet (day picker, chapter ticks, translation, streak, other plans), end-of-day card that steps through the day and rolls into the next, and chapter-level auto-marking. Each plan remembers its own translation (BSB, ESV, NLT, CSB, NASB, NET) and the Reader follows it while a plan is open. Plans home redesigned in the reader's themes with a Continue card, in-progress plans, a filterable catalog, and sheets for details and the custom-plan builder. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c11b6a60bf
commit
b1bc1c8ea4
17 files changed
+1185
-843
No files matched your search
+39
-3
@@ -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,
|
||||
|
||||
+33
-2
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||
<h3 className="mb-3 text-sm font-semibold text-[var(--r-text)]">{isEditing ? 'Edit reading plan' : 'New reading plan'}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-sm text-[var(--r-text)]">
|
||||
Title
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Advent Readings"
|
||||
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[var(--r-text)]">
|
||||
Days
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="mt-3 block text-sm text-[var(--r-text)]">
|
||||
Description <span className="text-[var(--r-sub)]">(optional)</span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-3">
|
||||
<span className="text-sm text-[var(--r-text)]">Cover photo <span className="text-[var(--r-sub)]">(optional)</span></span>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
{image ? (
|
||||
<img src={image} alt="" className="h-16 w-28 rounded-lg object-cover" />
|
||||
) : (
|
||||
<div className="flex h-16 w-28 items-center justify-center rounded-lg bg-[var(--r-bg)] text-2xl">🖼️</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-1.5 text-sm font-semibold text-[var(--r-text)] hover:bg-black/5"
|
||||
>
|
||||
{image ? 'Change photo' : 'Choose photo'}
|
||||
</button>
|
||||
{image && (
|
||||
<button type="button" onClick={() => setImage(null)} className="text-sm text-[var(--r-sub)] hover:text-rose-500">
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
|
||||
</div>
|
||||
{imageError && <p className="mt-1 text-xs text-rose-500">{imageError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--r-text)]">Books</span>
|
||||
{BOOK_PRESETS.map(([label, list]) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => applyPreset(list)}
|
||||
className="rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-2.5 py-1 text-xs font-semibold text-[var(--r-text)] hover:bg-black/5"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-[var(--r-sub)]">
|
||||
Check a book to include the whole thing, or narrow it to a chapter range with the fields that appear.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="max-h-56 overflow-y-auto rounded-xl border border-[var(--r-line)] p-2">
|
||||
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub)]">Old Testament</p>
|
||||
{bookOptions.filter((b) => OT_BOOKS.includes(b.abbrev)).map((b) => (
|
||||
<BookRow
|
||||
key={b.abbrev}
|
||||
book={b}
|
||||
checked={books.includes(b.abbrev)}
|
||||
range={chapterRanges[b.abbrev]}
|
||||
onToggle={() => toggleBook(b.abbrev)}
|
||||
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto rounded-xl border border-[var(--r-line)] p-2">
|
||||
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub)]">New Testament</p>
|
||||
{bookOptions.filter((b) => NT_BOOKS.includes(b.abbrev)).map((b) => (
|
||||
<BookRow
|
||||
key={b.abbrev}
|
||||
book={b}
|
||||
checked={books.includes(b.abbrev)}
|
||||
range={chapterRanges[b.abbrev]}
|
||||
onToggle={() => toggleBook(b.abbrev)}
|
||||
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[var(--r-sub)]">
|
||||
{books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters
|
||||
{days > 0 && totalChapters > 0 && ` · ~${(totalChapters / days).toFixed(1)} ch/day`}
|
||||
</p>
|
||||
{isEditing && (
|
||||
books.join() !== initialValues.books.join()
|
||||
|| Number(days) !== initialValues.days
|
||||
|| JSON.stringify(chapterRanges) !== JSON.stringify(initialValues.chapterRanges ?? {})
|
||||
) && (
|
||||
<p className="mt-2 text-xs text-amber-600">
|
||||
Changing the books, chapters, or day count restarts progress for any run of this plan already in your list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canCreate}
|
||||
onClick={() => onSubmit({ title, description, days, books, chapterRanges, image })}
|
||||
className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-semibold text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isEditing ? 'Save changes' : 'Create plan'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="text-sm text-[var(--r-sub)] hover:text-[var(--r-text)]">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One book row in the custom-plan builder: a checkbox, and — once checked — a
|
||||
// "ch. X–Y" chapter-range pair that defaults to the whole book.
|
||||
function BookRow({ book, checked, range, onToggle, onRangeChange }) {
|
||||
const total = CHAPTER_COUNTS[book.abbrev] ?? 1;
|
||||
return (
|
||||
<label className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-black/5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onToggle}
|
||||
className="h-4 w-4 shrink-0 rounded border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] accent-[var(--r-accent)]"
|
||||
/>
|
||||
<span className="flex-1">{book.name}</span>
|
||||
{checked && total > 1 && (
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-[var(--r-sub)]" onClick={(e) => e.preventDefault()}>
|
||||
ch.
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={total}
|
||||
value={range?.start ?? 1}
|
||||
onChange={(e) => onRangeChange('start', e.target.value)}
|
||||
className="w-11 rounded border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-1 py-0.5 text-center"
|
||||
/>
|
||||
–
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={total}
|
||||
value={range?.end ?? total}
|
||||
onChange={(e) => onRangeChange('end', e.target.value)}
|
||||
className="w-11 rounded border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-1 py-0.5 text-center"
|
||||
/>
|
||||
<span className="text-[var(--r-sub)]">/{total}</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Detail for one plan. Two modes: an in-progress plan (days to tick or jump into,
|
||||
// its translation, catch-up / restart / remove) and a catalog plan (preview of the
|
||||
// first days, pick a translation, start it).
|
||||
import { useRef, useState } from 'react';
|
||||
import Sheet from '../reader/Sheet.jsx';
|
||||
import TranslationSelect from './TranslationSelect.jsx';
|
||||
import { resizeImageToDataUrl } from './CustomPlanBuilder.jsx';
|
||||
import { formatDayReadings, getPlanSchedule, daysBehindPace } from '../../utils/readingPlans.js';
|
||||
|
||||
const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5';
|
||||
const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50';
|
||||
|
||||
function ActiveBody({ plan, progress, onClose, actions }) {
|
||||
const schedule = getPlanSchedule(plan.id);
|
||||
const behind = daysBehindPace(plan, progress);
|
||||
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between text-sm">
|
||||
<span>{progress.completedDays.length} of {plan.days} days · {pct}%</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--r-sub)]">Read in</span>
|
||||
<TranslationSelect value={progress.translation ?? actions.defaultTranslation} onChange={(t) => actions.setTranslation(progress.id, t)} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-3 h-1.5 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)]" style={{ width: `${pct}%` }} /></div>
|
||||
{behind >= 2 && (
|
||||
<div className="mb-3 flex items-center justify-between rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
|
||||
<span className="text-[var(--r-sub)]">{behind} days behind pace</span>
|
||||
<button type="button" onClick={() => actions.recalibrate(progress.id)} className="font-medium text-[var(--r-accent)] hover:underline">Catch me up</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[38vh] overflow-y-auto rounded-xl border border-[var(--r-line)]">
|
||||
{schedule.map((refs, i) => {
|
||||
const done = progress.completedDays.includes(i);
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3 border-b border-[var(--r-line)] px-3 py-2 last:border-0">
|
||||
<input type="checkbox" checked={done} onChange={() => actions.toggleDay(progress.id, i, plan.days)} className="h-4 w-4 shrink-0 accent-[var(--r-accent)]" aria-label={`Day ${i + 1} read`} />
|
||||
<span className={`flex-1 text-sm ${done ? 'text-[var(--r-sub)] line-through' : ''}`}>Day {i + 1} · {formatDayReadings(refs)}</span>
|
||||
<button type="button" onClick={() => { onClose(); actions.read(progress.id, i); }} className="shrink-0 text-xs font-medium text-[var(--r-accent)] hover:underline">Read</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between text-xs">
|
||||
<button type="button" onClick={() => { if (window.confirm('Restart this plan from day 1?')) { actions.restart(progress.id); onClose(); } }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
|
||||
<button type="button" onClick={() => { if (window.confirm('Remove this plan?')) { actions.remove(progress.id); onClose(); } }} className="text-red-600 hover:underline">Remove</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogBody({ plan, active, completedCount, onClose, actions }) {
|
||||
const [translation, setTranslation] = useState(actions.defaultTranslation);
|
||||
const [imageError, setImageError] = useState('');
|
||||
const fileRef = useRef(null);
|
||||
const schedule = getPlanSchedule(plan.id);
|
||||
const chapters = schedule.reduce((n, d) => n + d.length, 0);
|
||||
const pickImage = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
try { setImageError(''); actions.updateCover(plan.id, await resizeImageToDataUrl(file)); } catch { setImageError('Couldn’t use that image.'); }
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{plan.image && <img src={plan.image} alt="" onError={(e) => { e.currentTarget.style.display = 'none'; }} className="mb-3 h-32 w-full rounded-xl object-cover" style={{ objectPosition: plan.imageFocus ?? 'center' }} />}
|
||||
<p className="text-sm text-[var(--r-sub)]">{plan.description || 'A custom reading plan.'}</p>
|
||||
<p className="mt-2 text-xs text-[var(--r-sub)]">{plan.days} days · {chapters} chapters · about {(chapters / plan.days).toFixed(1)} a day{completedCount > 0 ? ` · completed ${completedCount}×` : ''}</p>
|
||||
|
||||
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">First days</p>
|
||||
<div className="rounded-xl border border-[var(--r-line)]">
|
||||
{schedule.slice(0, 5).map((refs, i) => (
|
||||
<div key={i} className="border-b border-[var(--r-line)] px-3 py-1.5 text-sm last:border-0">Day {i + 1} · {formatDayReadings(refs)}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!active && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<span>Read in</span>
|
||||
<TranslationSelect value={translation} onChange={setTranslation} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
{active
|
||||
? <button type="button" className={primary} onClick={() => { onClose(); actions.read(active.id, actions.nextDay(plan, active)); }}>Continue reading</button>
|
||||
: <button type="button" className={primary} onClick={() => { actions.start(plan.id, translation); onClose(); }}>{completedCount > 0 ? 'Start again' : 'Start plan'}</button>}
|
||||
</div>
|
||||
|
||||
{plan.custom && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[var(--r-line)] pt-3 text-xs">
|
||||
<button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button>
|
||||
<button type="button" onClick={() => fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}</button>
|
||||
{plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-red-600">Remove cover</button>}
|
||||
<button type="button" onClick={() => { if (window.confirm('Delete this plan? Its progress history will be removed too.')) { actions.delete(plan.id); onClose(); } }} className="ml-auto text-red-600 hover:underline">Delete</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
|
||||
{imageError && <p className="w-full text-red-600">{imageError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlanDetailSheet({ plan, progress, active, completedCount, onClose, actions }) {
|
||||
if (!plan) return null;
|
||||
return (
|
||||
<Sheet open onClose={onClose} title={<span>{plan.icon ? `${plan.icon} ` : ''}{plan.title}</span>}>
|
||||
{progress
|
||||
? <ActiveBody plan={plan} progress={progress} onClose={onClose} actions={actions} />
|
||||
: <CatalogBody plan={plan} active={active} completedCount={completedCount} onClose={onClose} actions={actions} />}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
|
||||
|
||||
// Every Bible the app can read in — used for a plan's own translation.
|
||||
export default function TranslationSelect({ value, onChange, className = '' }) {
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}
|
||||
className={`rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)] ${className}`}>
|
||||
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
|
||||
const NT_START = bookOptions.findIndex((b) => b.abbrev === 'MAT');
|
||||
const chip = 'rounded-lg border px-2 py-2 text-center text-sm transition';
|
||||
|
||||
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, onGo }) {
|
||||
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo }) {
|
||||
const [pickedBook, setPickedBook] = useState(null); // book abbrev whose chapters are showing
|
||||
const close = () => { setPickedBook(null); onClose(); };
|
||||
const book = pickedBook ? bookOptions.find((b) => b.abbrev === pickedBook) : null;
|
||||
@@ -33,6 +33,7 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{translationNote && <p className="-mt-1 mb-3 text-[11px] text-[var(--r-sub)]">{translationNote}</p>}
|
||||
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Old Testament</p>
|
||||
<div className="mb-4 grid grid-cols-3 gap-1.5 sm:grid-cols-4">{bookOptions.slice(0, NT_START).map(bookButton)}</div>
|
||||
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">New Testament</p>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Card at the end of the chapter while reading a plan: step to the next chapter of
|
||||
// today's reading, or finish the day and roll into the next one.
|
||||
export default function PlanFooter({ info, label, onNext, onNextDay, onFinish }) {
|
||||
if (!info || info.position === 0) return null;
|
||||
const last = info.position === info.total;
|
||||
const nextRef = !last ? info.refs[info.position] : null;
|
||||
const btn = 'rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)]';
|
||||
return (
|
||||
<div className="mx-auto mt-10 max-w-md rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 text-center font-sans">
|
||||
<p className="text-xs text-[var(--r-sub)]">{info.plan.title} · Day {info.dayIndex + 1}</p>
|
||||
{!last && (
|
||||
<>
|
||||
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">{info.total - info.position} more chapter{info.total - info.position === 1 ? '' : 's'} today</p>
|
||||
<button type="button" onClick={onNext} className={btn}>Next: {label(nextRef)}</button>
|
||||
</>
|
||||
)}
|
||||
{last && info.nextDayRefs?.length > 0 && (
|
||||
<>
|
||||
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">That’s Day {info.dayIndex + 1}</p>
|
||||
<button type="button" onClick={onNextDay} className={btn}>Mark read · Day {info.dayIndex + 2}: {label(info.nextDayRefs[0])}</button>
|
||||
<div><button type="button" onClick={onFinish} className="mt-2 text-xs text-[var(--r-sub)] underline">Mark read and stop for today</button></div>
|
||||
</>
|
||||
)}
|
||||
{last && !info.nextDayRefs?.length && (
|
||||
<>
|
||||
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">Last day of the plan</p>
|
||||
<button type="button" onClick={onFinish} className={btn}>{info.done ? 'Plan complete' : 'Finish plan'}</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// The plan's control room, one tap from the reader: pick the day, tick chapters,
|
||||
// choose the Bible this plan is read in, and jump to another active plan.
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { ChevronLeft, ChevronRight, CheckIcon } from './icons.jsx';
|
||||
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
|
||||
import { formatDayReadings } from '../../utils/readingPlans.js';
|
||||
|
||||
export default function PlanSheet({
|
||||
open, onClose, info, translation, currentKey, streak, otherPlans, studyBusy,
|
||||
onDay, onToggleChapter, onGoChapter, onTranslation, onToggleDay, onSwitchPlan, onListen, onStudy, onLeave,
|
||||
}) {
|
||||
if (!info) return null;
|
||||
const label = (r) => formatDayReadings([r]);
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title={info.plan.title}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<button type="button" onClick={() => onDay(-1)} disabled={info.dayIndex <= 0} aria-label="Previous day"
|
||||
className="rounded-lg p-1.5 text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-30"><ChevronLeft /></button>
|
||||
<span className="text-sm font-medium">Day {info.dayIndex + 1} of {info.plan.days}{info.done ? ' · read' : ''}</span>
|
||||
<button type="button" onClick={() => onDay(1)} disabled={info.dayIndex >= info.plan.days - 1} aria-label="Next day"
|
||||
className="rounded-lg p-1.5 text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-30"><ChevronRight /></button>
|
||||
</div>
|
||||
|
||||
<p className="mb-1 mt-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Reading</p>
|
||||
{info.refs.map((r) => {
|
||||
const key = `${r.book}.${r.chapter}`;
|
||||
const read = info.readSet.has(key);
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3 border-b border-[var(--r-line)] py-2">
|
||||
<button type="button" onClick={() => onToggleChapter(key)} aria-label={read ? 'Mark unread' : 'Mark read'}
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-[1.5px] ${read ? 'border-[var(--r-accent)] bg-[var(--r-accent)] text-[var(--r-on)]' : 'border-[var(--r-sub)] text-transparent'}`}>
|
||||
<span className="scale-75"><CheckIcon /></span>
|
||||
</button>
|
||||
<button type="button" onClick={() => onGoChapter(r)} className={`flex-1 text-left text-sm ${key === currentKey ? 'font-medium' : ''}`}>{label(r)}</button>
|
||||
{key === currentKey && <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[10px] text-[var(--r-sub)]">reading</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button type="button" onClick={onToggleDay}
|
||||
className="mt-3 w-full rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">
|
||||
{info.done ? 'Mark this day unread' : 'Mark the whole day read'}
|
||||
</button>
|
||||
|
||||
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">This plan</p>
|
||||
<div className="flex items-center justify-between py-1.5 text-sm">
|
||||
<span>Read in</span>
|
||||
<select value={translation} onChange={(e) => onTranslation(e.target.value)}
|
||||
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)]">
|
||||
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 text-sm">
|
||||
<span>Streak</span><span className="text-[var(--r-sub)]">{streak.current} day{streak.current === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button type="button" onClick={onListen} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">Listen</button>
|
||||
<button type="button" onClick={onStudy} disabled={studyBusy} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5 disabled:opacity-50">{studyBusy ? 'Building…' : 'Study this day'}</button>
|
||||
</div>
|
||||
|
||||
{otherPlans.length > 0 && (
|
||||
<>
|
||||
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Other plans</p>
|
||||
{otherPlans.map((o) => (
|
||||
<button key={o.progress.id} type="button" onClick={() => onSwitchPlan(o)}
|
||||
className="flex w-full items-center justify-between rounded-lg px-1 py-2 text-left text-sm hover:bg-black/5">
|
||||
<span className="truncate">{o.plan.title}</span>
|
||||
<span className="ml-3 shrink-0 text-xs text-[var(--r-sub)]">Day {o.dayIndex + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<button type="button" onClick={onLeave} className="mt-4 w-full text-center text-xs text-[var(--r-sub)] underline">Leave plan (keep reading on my own)</button>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,39 @@
|
||||
// Thin strip under the top bar while the open chapter belongs to an active
|
||||
// reading plan. Shows which day you're on and lets you mark it read.
|
||||
export default function PlanStrip({ matches, notice, onToggle }) {
|
||||
if (!matches.length && !notice) return null;
|
||||
// Thin strip under the top bar while reading a plan: which plan and day, one dot
|
||||
// per chapter of today's reading (filled = read, ringed = the chapter on screen),
|
||||
// and the translation. Tapping it opens the plan sheet. When you've wandered off
|
||||
// today's chapters (e.g. following a cross-reference) it offers a way back.
|
||||
export default function PlanStrip({ info, translation, inPlan, notice, onOpen, onJoin, onDot, onBackToDay }) {
|
||||
if (!info && !notice) return null;
|
||||
return (
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-1 px-4 pt-2 font-sans">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-1 px-3 pt-2 font-sans">
|
||||
{notice && <p className="rounded-lg bg-[var(--r-bar)] px-3 py-1.5 text-xs text-[var(--r-accent)]">{notice}</p>}
|
||||
{matches.map((m) => (
|
||||
<button key={m.progressId} type="button" onClick={() => onToggle(m)}
|
||||
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--r-bar)] px-3 py-1.5 text-left text-xs text-[var(--r-sub)] hover:bg-black/5"
|
||||
title={m.done ? 'Tap to unmark this day' : 'Tap to mark this day read'}>
|
||||
<span className="truncate"><span className="text-[var(--r-accent)]">{m.done ? '✓' : '●'}</span> {m.plan.title} · Day {m.dayIndex + 1} of {m.plan.days}</span>
|
||||
<span className="shrink-0">{m.total > 1 ? `${m.position} of ${m.total}` : m.done ? 'Read' : 'Mark read'}</span>
|
||||
{info && (
|
||||
<div className="rounded-xl bg-[var(--r-bar)] px-3 py-2 text-xs text-[var(--r-sub)]">
|
||||
<button type="button" onClick={inPlan ? onOpen : onJoin} className="flex w-full items-center justify-between gap-3 text-left">
|
||||
<span className="truncate font-medium text-[var(--r-text)]">{info.plan.title} · Day {info.dayIndex + 1}</span>
|
||||
<span className="shrink-0">{inPlan ? translation : 'Open plan'}</span>
|
||||
</button>
|
||||
))}
|
||||
{inPlan && info.position === 0 && (
|
||||
<button type="button" onClick={onBackToDay} className="mt-1.5 rounded-full border border-[var(--r-line)] px-3 py-1 text-[11px] text-[var(--r-accent)] hover:bg-black/5">
|
||||
Back to today’s reading
|
||||
</button>
|
||||
)}
|
||||
{inPlan && info.position > 0 && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
{info.refs.map((r, i) => {
|
||||
const key = `${r.book}.${r.chapter}`;
|
||||
const read = info.readSet.has(key);
|
||||
const current = i + 1 === info.position;
|
||||
return (
|
||||
<button key={key} type="button" onClick={() => onDot(r)} aria-label={`Go to chapter ${r.chapter}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border-[1.5px] ${read ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'} ${current ? 'ring-2 ring-[var(--r-line)]' : ''}`} />
|
||||
);
|
||||
})}
|
||||
<span className="ml-auto">{info.done ? 'Day read' : `${info.position} of ${info.total}`}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { formatCrossRef } from '../../utils/wordStudy.js';
|
||||
export default function ReaderText({
|
||||
title, translation, notice, verses, loading, error, layout, fontSize, wide,
|
||||
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
|
||||
crossRefs, showCrossRefs, onSelectionEnd,
|
||||
crossRefs, showCrossRefs, onSelectionEnd, footer,
|
||||
}) {
|
||||
const handleTap = (n) => {
|
||||
const sel = window.getSelection();
|
||||
@@ -73,6 +73,8 @@ export default function ReaderText({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && footer}
|
||||
|
||||
{notice && !loading && <p className="mt-10 border-t border-[var(--r-line)] pt-3 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{notice}</p>}
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -21,15 +21,18 @@ export const READER_TRANSLATIONS = [
|
||||
|
||||
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
|
||||
|
||||
export function useBibleReader(currentPage) {
|
||||
// `translationOverride` lets a reading plan pin the Bible it is read in while it is
|
||||
// open; otherwise the reader's own (remembered) translation is used.
|
||||
export function useBibleReader(currentPage, translationOverride = null) {
|
||||
const [readerBookAbbrev, setReaderBookAbbrev] = useState(bookOptions[0].abbrev);
|
||||
const [readerChapter, setReaderChapter] = useState(1);
|
||||
const [readerTranslation, setReaderTranslationState] = useState(() => {
|
||||
const [baseTranslation, setReaderTranslationState] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('reader-translation');
|
||||
return READER_TRANSLATIONS.some((t) => t.id === saved) ? saved : 'BSB';
|
||||
} catch { return 'BSB'; }
|
||||
});
|
||||
const readerTranslation = translationOverride ?? baseTranslation;
|
||||
const [readerCopyright, setReaderCopyright] = useState('');
|
||||
const [readerTheme, setReaderThemeState] = useState(() => {
|
||||
try {
|
||||
|
||||
@@ -85,7 +85,7 @@ export function useReadingPlans() {
|
||||
setState((prev) => ({ ...prev, customPlans: updater(prev.customPlans) }));
|
||||
}, []);
|
||||
|
||||
const startPlan = useCallback((planId) => {
|
||||
const startPlan = useCallback((planId, translation) => {
|
||||
updateProgress((prev) => {
|
||||
if (prev.some((p) => p.planId === planId && !p.completedAt)) return prev;
|
||||
const record = {
|
||||
@@ -95,37 +95,80 @@ export function useReadingPlans() {
|
||||
completedDays: [],
|
||||
completedAt: null,
|
||||
lastActivityAt: Date.now(),
|
||||
translation: translation || undefined, // which Bible this plan is read in (undefined = the reader's default)
|
||||
chaptersRead: {}, // dayIndex -> ['JHN.8', ...] chapters finished within that day
|
||||
};
|
||||
return [record, ...prev];
|
||||
});
|
||||
}, [updateProgress]);
|
||||
|
||||
const toggleDay = useCallback((progressId, dayIndex, totalDays) => {
|
||||
// Applies a change to one plan's progress record and keeps the cross-plan
|
||||
// activity log in step: finishing any day (for the first time today) extends the streak.
|
||||
const patchProgress = useCallback((progressId, patcher, totalDays) => {
|
||||
setState((prev) => {
|
||||
const target = prev.progress.find((p) => p.id === progressId);
|
||||
if (!target) return prev;
|
||||
const wasAlready = target.completedDays.includes(dayIndex);
|
||||
let newlyDone = false;
|
||||
const progress = prev.progress.map((p) => {
|
||||
if (p.id !== progressId) return p;
|
||||
const completedDays = wasAlready
|
||||
? p.completedDays.filter((d) => d !== dayIndex)
|
||||
: [...p.completedDays, dayIndex].sort((a, b) => a - b);
|
||||
const completedAt = completedDays.length >= totalDays ? (p.completedAt ?? Date.now()) : null;
|
||||
return { ...p, completedDays, completedAt, lastActivityAt: Date.now() };
|
||||
const next = patcher(p);
|
||||
newlyDone = next.completedDays.length > p.completedDays.length;
|
||||
const completedAt = totalDays != null && next.completedDays.length >= totalDays ? (p.completedAt ?? Date.now()) : (totalDays != null ? null : p.completedAt);
|
||||
return { ...next, completedAt, lastActivityAt: Date.now() };
|
||||
});
|
||||
// Only marking a day *done* extends the streak; unchecking one doesn't erase it.
|
||||
const today = localDateString();
|
||||
const activity = !wasAlready && !prev.activity.includes(today)
|
||||
? [...prev.activity, today]
|
||||
: prev.activity;
|
||||
const activity = newlyDone && !prev.activity.includes(today) ? [...prev.activity, today] : prev.activity;
|
||||
return { ...prev, progress, activity };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const withoutDay = (chaptersRead, dayIndex) => {
|
||||
const { [dayIndex]: _removed, ...rest } = chaptersRead ?? {};
|
||||
return rest;
|
||||
};
|
||||
|
||||
// Marks a whole day done/undone. Undoing also clears that day's per-chapter ticks.
|
||||
const toggleDay = useCallback((progressId, dayIndex, totalDays) => {
|
||||
patchProgress(progressId, (p) => {
|
||||
const wasAlready = p.completedDays.includes(dayIndex);
|
||||
return {
|
||||
...p,
|
||||
completedDays: wasAlready
|
||||
? p.completedDays.filter((d) => d !== dayIndex)
|
||||
: [...p.completedDays, dayIndex].sort((a, b) => a - b),
|
||||
chaptersRead: wasAlready ? withoutDay(p.chaptersRead, dayIndex) : p.chaptersRead,
|
||||
};
|
||||
}, totalDays);
|
||||
}, [patchProgress]);
|
||||
|
||||
// Ticks (or unticks) one chapter of a day. `dayKeys` are all of that day's chapter
|
||||
// keys ("JHN.8"); once every one is ticked the day completes by itself, and
|
||||
// unticking a chapter of a finished day reopens it. `force` = true/false sets it
|
||||
// instead of toggling (used by the auto-mark so it can't accidentally untick).
|
||||
const toggleChapterRead = useCallback((progressId, dayIndex, chapterKey, dayKeys, totalDays, force) => {
|
||||
patchProgress(progressId, (p) => {
|
||||
const read = new Set((p.chaptersRead ?? {})[dayIndex] ?? []);
|
||||
const want = force ?? !read.has(chapterKey);
|
||||
if (want) read.add(chapterKey); else read.delete(chapterKey);
|
||||
const allRead = dayKeys.every((k) => read.has(k));
|
||||
const isDone = p.completedDays.includes(dayIndex);
|
||||
let completedDays = p.completedDays;
|
||||
if (allRead && !isDone) completedDays = [...completedDays, dayIndex].sort((a, b) => a - b);
|
||||
if (!allRead && isDone) completedDays = completedDays.filter((d) => d !== dayIndex);
|
||||
return { ...p, completedDays, chaptersRead: { ...(p.chaptersRead ?? {}), [dayIndex]: [...read] } };
|
||||
}, totalDays);
|
||||
}, [patchProgress]);
|
||||
|
||||
// Which Bible a plan is read in (any translation the reader offers).
|
||||
const setPlanTranslation = useCallback((progressId, translation) => {
|
||||
updateProgress((prev) => prev.map((p) => (p.id === progressId ? { ...p, translation } : p)));
|
||||
}, [updateProgress]);
|
||||
|
||||
const restartPlan = useCallback((progressId) => {
|
||||
updateProgress((prev) => prev.map((p) => (
|
||||
p.id === progressId
|
||||
? { ...p, completedDays: [], completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
|
||||
? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
|
||||
: p
|
||||
)));
|
||||
}, [updateProgress]);
|
||||
@@ -179,7 +222,7 @@ export function useReadingPlans() {
|
||||
if (resetProgress) {
|
||||
updateProgress((prev) => prev.map((p) => (
|
||||
p.planId === planId
|
||||
? { ...p, completedDays: [], completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
|
||||
? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
|
||||
: p
|
||||
)));
|
||||
}
|
||||
@@ -243,6 +286,8 @@ export function useReadingPlans() {
|
||||
retryReadingPlanSync,
|
||||
startPlan,
|
||||
toggleDay,
|
||||
toggleChapterRead,
|
||||
setPlanTranslation,
|
||||
restartPlan,
|
||||
removePlanProgress,
|
||||
recalibratePace,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../syncService.js', () => ({
|
||||
getRemoteReadingProgress: vi.fn(async () => ({ ok: true, data: {} })),
|
||||
saveRemoteReadingProgress: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
import { useReadingPlans } from './useReadingPlans.js';
|
||||
|
||||
beforeEach(() => { localStorage.clear(); });
|
||||
|
||||
const start = (translation) => {
|
||||
const hook = renderHook(() => useReadingPlans());
|
||||
act(() => hook.result.current.startPlan('gospels-90', translation));
|
||||
return hook;
|
||||
};
|
||||
const rec = (hook) => hook.result.current.planProgress[0];
|
||||
|
||||
test('a started plan remembers its translation', () => {
|
||||
const hook = start('ESV');
|
||||
expect(rec(hook).translation).toBe('ESV');
|
||||
act(() => hook.result.current.setPlanTranslation(rec(hook).id, 'NLT'));
|
||||
expect(rec(hook).translation).toBe('NLT');
|
||||
});
|
||||
|
||||
test('ticking every chapter of a day completes it, and unticking reopens it', () => {
|
||||
const hook = start('BSB');
|
||||
const id = rec(hook).id;
|
||||
const keys = ['JHN.8', 'JHN.9'];
|
||||
act(() => hook.result.current.toggleChapterRead(id, 11, 'JHN.8', keys, 90));
|
||||
expect(rec(hook).completedDays).toEqual([]);
|
||||
expect(rec(hook).chaptersRead[11]).toEqual(['JHN.8']);
|
||||
act(() => hook.result.current.toggleChapterRead(id, 11, 'JHN.9', keys, 90));
|
||||
expect(rec(hook).completedDays).toEqual([11]);
|
||||
expect(hook.result.current.readingActivity.length).toBe(1); // streak extended
|
||||
act(() => hook.result.current.toggleChapterRead(id, 11, 'JHN.9', keys, 90));
|
||||
expect(rec(hook).completedDays).toEqual([]);
|
||||
});
|
||||
|
||||
test('force=true never unticks a chapter that is already read', () => {
|
||||
const hook = start('BSB');
|
||||
const id = rec(hook).id;
|
||||
act(() => hook.result.current.toggleChapterRead(id, 0, 'JHN.1', ['JHN.1', 'JHN.2'], 90, true));
|
||||
act(() => hook.result.current.toggleChapterRead(id, 0, 'JHN.1', ['JHN.1', 'JHN.2'], 90, true));
|
||||
expect(rec(hook).chaptersRead[0]).toEqual(['JHN.1']);
|
||||
});
|
||||
|
||||
test('unmarking a whole day clears its chapter ticks', () => {
|
||||
const hook = start('BSB');
|
||||
const id = rec(hook).id;
|
||||
act(() => hook.result.current.toggleDay(id, 3, 90));
|
||||
expect(rec(hook).completedDays).toEqual([3]);
|
||||
act(() => hook.result.current.toggleChapterRead(id, 3, 'JHN.4', ['JHN.4'], 90, true));
|
||||
act(() => hook.result.current.toggleDay(id, 3, 90));
|
||||
expect(rec(hook).completedDays).toEqual([]);
|
||||
expect(rec(hook).chaptersRead[3]).toBeUndefined();
|
||||
});
|
||||
|
||||
test('finishing the last day completes the plan', () => {
|
||||
const hook = start('BSB');
|
||||
const id = rec(hook).id;
|
||||
act(() => { for (let d = 0; d < 90; d += 1) hook.result.current.toggleDay(id, d, 90); });
|
||||
expect(rec(hook).completedAt).toBeTruthy();
|
||||
act(() => hook.result.current.toggleDay(id, 5, 90));
|
||||
expect(rec(hook).completedAt).toBeNull();
|
||||
});
|
||||
+113
-45
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { bookOptions } from '../utils/bibleUtils.jsx';
|
||||
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings } from '../utils/readingPlans.js';
|
||||
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
|
||||
import DrawCanvas from './DrawCanvas.jsx';
|
||||
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
|
||||
import { useBibleSearch } from '../hooks/useBibleSearch.js';
|
||||
@@ -17,6 +17,8 @@ import SearchSheet from '../components/reader/SearchSheet.jsx';
|
||||
import BookmarksSheet from '../components/reader/BookmarksSheet.jsx';
|
||||
import AudioSheet from '../components/reader/AudioSheet.jsx';
|
||||
import VerseSheet from '../components/reader/VerseSheet.jsx';
|
||||
import PlanSheet from '../components/reader/PlanSheet.jsx';
|
||||
import PlanFooter from '../components/reader/PlanFooter.jsx';
|
||||
import { FocusIcon } from '../components/reader/icons.jsx';
|
||||
|
||||
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
|
||||
@@ -68,6 +70,12 @@ export default function ReaderPage() {
|
||||
removeTextHighlight,
|
||||
planProgress,
|
||||
toggleDay,
|
||||
toggleChapterRead,
|
||||
readingActivity,
|
||||
readerPlan, setReaderPlan,
|
||||
openPlanReading,
|
||||
studyReadingPlanDay,
|
||||
studyBuildBusy,
|
||||
} = useApp();
|
||||
|
||||
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
|
||||
@@ -83,39 +91,67 @@ export default function ReaderPage() {
|
||||
|
||||
useEffect(() => { localStorage.setItem('reader-wide', readerWideLayout ? '1' : '0'); }, [readerWideLayout]);
|
||||
|
||||
// Active reading plans whose schedule includes the chapter currently on screen.
|
||||
const planMatches = useMemo(() => {
|
||||
return planProgress
|
||||
.filter((p) => !p.completedAt)
|
||||
const chapterKey = `${readerBookAbbrev}.${readerChapter}`;
|
||||
const label = (ref) => formatDayReadings([ref]);
|
||||
|
||||
// Everything the plan UI needs to know about one plan-day, relative to the chapter on screen.
|
||||
const planInfo = (progress, dayIndex) => {
|
||||
const plan = progress && getPlan(progress.planId);
|
||||
if (!plan) return null;
|
||||
const schedule = getPlanSchedule(progress.planId);
|
||||
const refs = schedule[dayIndex] ?? [];
|
||||
const keys = refs.map((r) => `${r.book}.${r.chapter}`);
|
||||
const done = progress.completedDays.includes(dayIndex);
|
||||
const readSet = new Set(done ? keys : progress.chaptersRead?.[dayIndex] ?? []);
|
||||
return {
|
||||
progress, plan, dayIndex, refs, keys, done, readSet,
|
||||
position: keys.indexOf(chapterKey) + 1, // 0 = the chapter on screen isn't part of this day
|
||||
total: refs.length,
|
||||
nextDayRefs: schedule[dayIndex + 1] ?? null,
|
||||
translation: progress.translation,
|
||||
};
|
||||
};
|
||||
|
||||
// The plan day open in the reader (set when you read from a plan) ...
|
||||
const ctxInfo = useMemo(() => {
|
||||
if (!readerPlan) return null;
|
||||
return planInfo(planProgress.find((p) => p.id === readerPlan.progressId), readerPlan.dayIndex);
|
||||
}, [planProgress, readerPlan, chapterKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ... and any other active plans whose schedule includes the chapter on screen.
|
||||
const otherMatches = useMemo(() => planProgress
|
||||
.filter((p) => !p.completedAt && p.id !== readerPlan?.progressId)
|
||||
.map((p) => {
|
||||
const plan = getPlan(p.planId);
|
||||
if (!plan) return null;
|
||||
const schedule = getPlanSchedule(p.planId);
|
||||
const dayIndex = findDayIndexForRef(schedule, readerBookAbbrev, readerChapter);
|
||||
if (dayIndex === -1) return null;
|
||||
const refs = schedule[dayIndex];
|
||||
const position = refs.findIndex((r) => r.book === readerBookAbbrev && r.chapter === readerChapter) + 1;
|
||||
return { progressId: p.id, plan, dayIndex, refs, position, total: refs.length, done: p.completedDays.includes(dayIndex), nextDayRefs: schedule[dayIndex + 1] ?? null };
|
||||
const dayIndex = plan ? findDayIndexForRef(getPlanSchedule(p.planId), readerBookAbbrev, readerChapter) : -1;
|
||||
return dayIndex === -1 ? null : planInfo(p, dayIndex);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}, [planProgress, readerBookAbbrev, readerChapter]);
|
||||
.filter(Boolean), [planProgress, readerPlan, chapterKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const stripInfo = ctxInfo ?? otherMatches[0] ?? null;
|
||||
|
||||
// Auto-marks matching plan days as read after a dwell period on the chapter,
|
||||
// so you don't have to remember to tap "Mark read" yourself. Resets whenever
|
||||
// you navigate to a different book/chapter; a manual unmark isn't re-marked.
|
||||
const planMatchesRef = useRef(planMatches);
|
||||
planMatchesRef.current = planMatches;
|
||||
const otherPlans = useMemo(() => planProgress
|
||||
.filter((p) => !p.completedAt && p.id !== readerPlan?.progressId)
|
||||
.map((p) => {
|
||||
const plan = getPlan(p.planId);
|
||||
return plan ? { progress: p, plan, dayIndex: nextUnreadDayIndex(plan, p.completedDays) ?? 0 } : null;
|
||||
})
|
||||
.filter(Boolean), [planProgress, readerPlan]);
|
||||
|
||||
// After a dwell period on a plan chapter it's ticked off automatically, so you don't
|
||||
// have to remember to. Resets whenever you change chapter; ticks are never removed here.
|
||||
const autoMarkRef = useRef([]);
|
||||
autoMarkRef.current = [ctxInfo?.position > 0 ? ctxInfo : null, ...otherMatches].filter(Boolean);
|
||||
const [autoMarkedNotice, setAutoMarkedNotice] = useState('');
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const justMarked = planMatchesRef.current.filter((m) => !m.done);
|
||||
if (justMarked.length === 0) return;
|
||||
justMarked.forEach((m) => toggleDay(m.progressId, m.dayIndex, m.plan.days));
|
||||
setAutoMarkedNotice(`Marked read: ${justMarked.map((m) => m.plan.title).join(', ')}`);
|
||||
window.setTimeout(() => setAutoMarkedNotice(''), 4000);
|
||||
const fresh = autoMarkRef.current.filter((m) => !m.readSet.has(chapterKey));
|
||||
if (fresh.length === 0) return;
|
||||
fresh.forEach((m) => toggleChapterRead(m.progress.id, m.dayIndex, chapterKey, m.keys, m.plan.days, true));
|
||||
setAutoMarkedNotice(`Marked read: ${label({ book: readerBookAbbrev, chapter: readerChapter })}`);
|
||||
window.setTimeout(() => setAutoMarkedNotice(''), 3500);
|
||||
}, 20000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [readerBookAbbrev, readerChapter, toggleDay]);
|
||||
}, [chapterKey, toggleChapterRead]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const goTo = (book, chapter) => {
|
||||
setReaderBookAbbrev(book);
|
||||
@@ -124,24 +160,26 @@ export default function ReaderPage() {
|
||||
setReaderCrossRefs(null);
|
||||
window.scrollTo({ top: 0 });
|
||||
};
|
||||
const goToRef = (r) => goTo(r.book, r.chapter);
|
||||
|
||||
// Bottom-bar action tied to the first plan that includes this chapter: step through
|
||||
// the day's chapters, then mark the day read and roll into the next day.
|
||||
const primary = useMemo(() => {
|
||||
const m = planMatches[0];
|
||||
if (!m) return null;
|
||||
const label = (ref) => formatDayReadings([ref]);
|
||||
if (m.position < m.total) {
|
||||
const next = m.refs[m.position];
|
||||
return { label: `Next: ${label(next)}`, done: true, onClick: () => goTo(next.book, next.chapter) };
|
||||
}
|
||||
const markDone = () => { if (!m.done) toggleDay(m.progressId, m.dayIndex, m.plan.days); };
|
||||
if (m.nextDayRefs?.length) {
|
||||
const first = m.nextDayRefs[0];
|
||||
return { label: m.done ? `Day ${m.dayIndex + 2}: ${label(first)}` : `Done · Day ${m.dayIndex + 2}`, done: m.done, onClick: () => { markDone(); goTo(first.book, first.chapter); } };
|
||||
}
|
||||
return m.done ? null : { label: 'Finish plan', done: false, onClick: markDone };
|
||||
}, [planMatches]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Opens a day of the context plan on its first unread chapter.
|
||||
const goDay = (progressId, dayIndex) => {
|
||||
const info = planInfo(planProgress.find((p) => p.id === progressId), dayIndex);
|
||||
if (!info) return;
|
||||
setReaderPlan({ progressId, dayIndex });
|
||||
const target = info.refs.find((r) => !info.readSet.has(`${r.book}.${r.chapter}`)) ?? info.refs[0];
|
||||
if (target) goToRef(target);
|
||||
};
|
||||
|
||||
// Ticks the chapter on screen; finishing a day's last chapter completes it. If earlier
|
||||
// chapters were skipped, the explicit "mark read" buttons still complete the whole day.
|
||||
const readCurrent = (info) => {
|
||||
if (info.readSet.has(chapterKey) && info.keys.every((k) => info.readSet.has(k))) return;
|
||||
const complete = info.keys.every((k) => k === chapterKey || info.readSet.has(k));
|
||||
if (complete) toggleChapterRead(info.progress.id, info.dayIndex, chapterKey, info.keys, info.plan.days, true);
|
||||
else if (!info.done && info.position === info.total) toggleDay(info.progress.id, info.dayIndex, info.plan.days);
|
||||
else toggleChapterRead(info.progress.id, info.dayIndex, chapterKey, info.keys, info.plan.days, true);
|
||||
};
|
||||
|
||||
// Whole-Bible search. BSB and NET are indexed in the browser (lowercased once, not per
|
||||
// keystroke, with typing kept responsive via useDeferredValue); ESV, NLT, CSB and NASB
|
||||
@@ -307,8 +345,15 @@ export default function ReaderPage() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{chromeVisible && <PlanStrip matches={planMatches} notice={autoMarkedNotice}
|
||||
onToggle={(m) => toggleDay(m.progressId, m.dayIndex, m.plan.days)} />}
|
||||
{chromeVisible && (
|
||||
<PlanStrip
|
||||
info={stripInfo} translation={readerTranslation} inPlan={!!ctxInfo} notice={autoMarkedNotice}
|
||||
onOpen={() => openSheet('plan')}
|
||||
onJoin={() => setReaderPlan({ progressId: stripInfo.progress.id, dayIndex: stripInfo.dayIndex })}
|
||||
onDot={goToRef}
|
||||
onBackToDay={() => goDay(ctxInfo.progress.id, ctxInfo.dayIndex)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={paneOpen ? 'lg:pr-[24rem]' : ''}>
|
||||
{!readerDrawMode && (
|
||||
@@ -332,6 +377,14 @@ export default function ReaderPage() {
|
||||
crossRefs={readerCrossRefs}
|
||||
showCrossRefs={readerShowCrossRefs}
|
||||
onSelectionEnd={handleSelectionEnd}
|
||||
footer={(
|
||||
<PlanFooter
|
||||
info={ctxInfo} label={label}
|
||||
onNext={() => { readCurrent(ctxInfo); goToRef(ctxInfo.refs[ctxInfo.position]); }}
|
||||
onNextDay={() => { readCurrent(ctxInfo); goDay(ctxInfo.progress.id, ctxInfo.dayIndex + 1); }}
|
||||
onFinish={() => { readCurrent(ctxInfo); setAutoMarkedNotice(`Day ${ctxInfo.dayIndex + 1} marked read`); window.setTimeout(() => setAutoMarkedNotice(''), 3500); }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -371,7 +424,7 @@ export default function ReaderPage() {
|
||||
onCrossRefs={() => { setReaderShowCrossRefs((v) => !v); if (!readerShowCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
|
||||
crossRefsOn={readerShowCrossRefs}
|
||||
onFocus={() => { setChromeVisible(false); setSheet(null); }}
|
||||
primary={primary}
|
||||
primary={null}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -395,10 +448,25 @@ export default function ReaderPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<PlanSheet
|
||||
open={sheet === 'plan'} onClose={closeSheet}
|
||||
info={ctxInfo} translation={readerTranslation} currentKey={chapterKey} streak={computeStreak(readingActivity)}
|
||||
otherPlans={otherPlans} studyBusy={studyBuildBusy}
|
||||
onDay={(d) => goDay(ctxInfo.progress.id, ctxInfo.dayIndex + d)}
|
||||
onToggleChapter={(key) => toggleChapterRead(ctxInfo.progress.id, ctxInfo.dayIndex, key, ctxInfo.keys, ctxInfo.plan.days)}
|
||||
onGoChapter={(r) => { goToRef(r); setSheet(null); }}
|
||||
onTranslation={setReaderTranslation}
|
||||
onToggleDay={() => toggleDay(ctxInfo.progress.id, ctxInfo.dayIndex, ctxInfo.plan.days)}
|
||||
onSwitchPlan={(o) => { setSheet(null); openPlanReading(o.progress.id, o.dayIndex); }}
|
||||
onListen={() => setSheet('audio')}
|
||||
onStudy={() => studyReadingPlanDay(ctxInfo.refs, ctxInfo.plan.title)}
|
||||
onLeave={() => { setReaderPlan(null); setSheet(null); }}
|
||||
/>
|
||||
<NavigatorSheet
|
||||
open={sheet === 'nav'} onClose={closeSheet}
|
||||
bookAbbrev={readerBookAbbrev} chapter={readerChapter}
|
||||
translation={readerTranslation} onTranslation={setReaderTranslation}
|
||||
translationNote={ctxInfo ? `Reading “${ctxInfo.plan.title}”: this sets the plan’s translation.` : ''}
|
||||
onGo={goTo}
|
||||
/>
|
||||
<SettingsSheet
|
||||
|
||||
@@ -25,7 +25,9 @@ const ctx = {
|
||||
handlePlayReaderAudio: noop, handleToggleReaderAudioPause: noop, handleStopBookAudio: noop,
|
||||
handleSeekAudio: noop, handleSeekAudioTo: noop,
|
||||
readerInkByPage: {}, updateReaderPageInk: noop, readerTextHighlights: [], addTextHighlight: noop, removeTextHighlight: noop,
|
||||
planProgress: [], toggleDay: noop,
|
||||
planProgress: [], toggleDay: vi.fn(), toggleChapterRead: vi.fn(), readingActivity: [],
|
||||
readerPlan: null, setReaderPlan: vi.fn(), openPlanReading: vi.fn(), studyReadingPlanDay: vi.fn(), studyBuildBusy: false,
|
||||
setReaderBookAbbrev: vi.fn(), setReaderChapter: vi.fn(),
|
||||
};
|
||||
vi.mock('../context/AppContext.js', () => ({ useApp: () => ctx }));
|
||||
|
||||
@@ -50,3 +52,72 @@ test('tapping a verse selects it', () => {
|
||||
fireEvent.click(screen.getByText(/This man came to Jesus/));
|
||||
expect(ctx.setReaderSelectedVerse).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
// --- reading plan integration -------------------------------------------------
|
||||
import { getPlanSchedule, findDayIndexForRef } from '../utils/readingPlans.js';
|
||||
|
||||
const schedule = getPlanSchedule('gospels-30');
|
||||
const dayIndex = findDayIndexForRef(schedule, 'JHN', 3);
|
||||
const refs = schedule[dayIndex];
|
||||
const keys = refs.map((r) => `${r.book}.${r.chapter}`);
|
||||
const position = keys.indexOf('JHN.3') + 1;
|
||||
const isLast = position === refs.length;
|
||||
|
||||
const withPlan = (over = {}) => {
|
||||
ctx.planProgress = [{ id: 'p1', planId: 'gospels-30', startedAt: Date.now(), completedDays: [], chaptersRead: {}, completedAt: null, translation: 'ESV', ...over }];
|
||||
ctx.readerPlan = { progressId: 'p1', dayIndex };
|
||||
[ctx.setReaderPlan, ctx.toggleChapterRead, ctx.toggleDay, ctx.setReaderChapter, ctx.setReaderBookAbbrev].forEach((f) => f.mockClear());
|
||||
ctx.setReaderTranslation = vi.fn();
|
||||
};
|
||||
const stripButton = () => screen.getAllByText(new RegExp(`Day ${dayIndex + 1} *$`))[0];
|
||||
|
||||
test('in a plan: strip shows the day, a dot per chapter, and the plan translation', () => {
|
||||
withPlan();
|
||||
render(<ReaderPage />);
|
||||
expect(screen.getAllByLabelText(/Go to chapter/)).toHaveLength(refs.length);
|
||||
expect(screen.getByText(`${position} of ${refs.length}`)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('end-of-day card ticks the current chapter and moves on', () => {
|
||||
// every other chapter of the day already read, so finishing this one completes the day
|
||||
withPlan({ chaptersRead: { [dayIndex]: keys.filter((k) => k !== 'JHN.3') } });
|
||||
render(<ReaderPage />);
|
||||
fireEvent.click(screen.getByText(isLast ? /Mark read · Day/ : /^Next: /));
|
||||
expect(ctx.toggleChapterRead).toHaveBeenCalledWith('p1', dayIndex, 'JHN.3', keys, 30, true);
|
||||
if (!isLast) expect(ctx.setReaderChapter).toHaveBeenCalledWith(refs[position].chapter);
|
||||
});
|
||||
|
||||
test('"mark read" on the last chapter completes the whole day even if earlier chapters were skipped', () => {
|
||||
const last = refs.at(-1);
|
||||
expect(refs.length).toBeGreaterThan(1);
|
||||
withPlan(); // nothing read yet
|
||||
ctx.readerBookAbbrev = last.book;
|
||||
ctx.readerChapter = last.chapter;
|
||||
try {
|
||||
render(<ReaderPage />);
|
||||
fireEvent.click(screen.getByText(/Mark read · Day/));
|
||||
expect(ctx.toggleDay).toHaveBeenCalledWith('p1', dayIndex, 30);
|
||||
} finally {
|
||||
ctx.readerBookAbbrev = 'JHN';
|
||||
ctx.readerChapter = 3;
|
||||
}
|
||||
});
|
||||
|
||||
test('plan sheet lists the day, changes the plan translation, and leaves the plan', () => {
|
||||
withPlan();
|
||||
render(<ReaderPage />);
|
||||
fireEvent.click(stripButton());
|
||||
expect(screen.getByText('Mark the whole day read')).toBeTruthy();
|
||||
fireEvent.change(screen.getByDisplayValue(/^ESV/), { target: { value: 'NLT' } });
|
||||
expect(ctx.setReaderTranslation).toHaveBeenCalledWith('NLT');
|
||||
fireEvent.click(screen.getByText(/Leave plan/));
|
||||
expect(ctx.setReaderPlan).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test('outside a plan but on a plan chapter: offers to open the plan', () => {
|
||||
withPlan();
|
||||
ctx.readerPlan = null;
|
||||
render(<ReaderPage />);
|
||||
fireEvent.click(screen.getByText('Open plan'));
|
||||
expect(ctx.setReaderPlan).toHaveBeenCalledWith({ progressId: 'p1', dayIndex });
|
||||
});
|
||||
+188
-763
@@ -1,167 +1,86 @@
|
||||
import { useRef, useState } from 'react';
|
||||
// Reading plans home, styled to match the reader (paper / sepia / dark). One big
|
||||
// "Continue reading" card, your plans in progress, and a browsable catalog. Details,
|
||||
// the custom-plan builder and everything else open as sheets.
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
|
||||
import AuthStatus from '../components/AuthStatus.jsx';
|
||||
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../utils/bibleUtils.jsx';
|
||||
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
|
||||
import { ChevronLeft } from '../components/reader/icons.jsx';
|
||||
import Sheet from '../components/reader/Sheet.jsx';
|
||||
import PlanDetailSheet from '../components/plans/PlanDetailSheet.jsx';
|
||||
import CustomPlanBuilder from '../components/plans/CustomPlanBuilder.jsx';
|
||||
import { formatRelativeDate } from '../utils/bibleUtils.jsx';
|
||||
import {
|
||||
READING_PLANS,
|
||||
getPlan,
|
||||
getPlanSchedule,
|
||||
formatDayReadings,
|
||||
firstRefOfDay,
|
||||
nextUnreadDayIndex,
|
||||
themeForCategory,
|
||||
computeStreak,
|
||||
daysBehindPace,
|
||||
bookChapterCount,
|
||||
ALL_BOOKS,
|
||||
OT_BOOKS,
|
||||
NT_BOOKS,
|
||||
GOSPELS,
|
||||
PENTATEUCH,
|
||||
WISDOM_BOOKS,
|
||||
READING_PLANS, getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak, daysBehindPace,
|
||||
} from '../utils/readingPlans.js';
|
||||
|
||||
const CATEGORY_ORDER = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical'];
|
||||
const THEME_ORDER = ['paper', 'sepia', 'dark'];
|
||||
|
||||
const pill = 'rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[10px] text-[var(--r-sub)]';
|
||||
const sectionTitle = 'mb-2 mt-7 text-[11px] uppercase tracking-wide text-[var(--r-sub)]';
|
||||
|
||||
function ProgressBar({ pct }) {
|
||||
return <div className="h-1.5 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full rounded-full bg-[var(--r-accent)]" style={{ width: `${pct}%` }} /></div>;
|
||||
}
|
||||
|
||||
// The next unread day of an in-progress plan, with its readings.
|
||||
function nextOf(progress) {
|
||||
const plan = getPlan(progress.planId);
|
||||
if (!plan) return null;
|
||||
const dayIndex = nextUnreadDayIndex(plan, progress.completedDays);
|
||||
if (dayIndex == null) return null;
|
||||
return { plan, dayIndex, refs: getPlanSchedule(progress.planId)[dayIndex] ?? [] };
|
||||
}
|
||||
|
||||
export default function ReadingPlansPage() {
|
||||
const {
|
||||
goHome,
|
||||
planProgress,
|
||||
customPlans,
|
||||
readingActivity,
|
||||
startPlan,
|
||||
toggleDay,
|
||||
restartPlan,
|
||||
removePlanProgress,
|
||||
recalibratePace,
|
||||
addCustomPlan,
|
||||
removeCustomPlan,
|
||||
updateCustomPlan,
|
||||
readReadingPlanDay,
|
||||
listenReadingPlanDay,
|
||||
studyReadingPlanDay,
|
||||
studyBuildBusy,
|
||||
readingPlanSyncStatus,
|
||||
readingPlanSyncError,
|
||||
retryReadingPlanSync,
|
||||
readerTranslation, setReaderTranslation,
|
||||
planProgress, customPlans, readingActivity,
|
||||
startPlan, toggleDay, restartPlan, removePlanProgress, recalibratePace, setPlanTranslation,
|
||||
addCustomPlan, removeCustomPlan, updateCustomPlan,
|
||||
openPlanReading, studyReadingPlanDay, studyBuildBusy,
|
||||
readingPlanSyncStatus, readingPlanSyncError, retryReadingPlanSync,
|
||||
readerTheme, setReaderTheme, readerTranslation,
|
||||
} = useApp();
|
||||
const [builderMode, setBuilderMode] = useState(null); // null | 'new' | a custom plan id to edit
|
||||
|
||||
const activeProgress = planProgress.filter((p) => !p.completedAt);
|
||||
const completedProgress = planProgress
|
||||
.filter((p) => p.completedAt)
|
||||
.sort((a, b) => b.completedAt - a.completedAt);
|
||||
const [detail, setDetail] = useState(null); // { planId, progressId? }
|
||||
const [builderMode, setBuilderMode] = useState(null); // null | 'new' | a custom plan id
|
||||
const [category, setCategory] = useState('All');
|
||||
|
||||
const active = planProgress.filter((p) => !p.completedAt && getPlan(p.planId)).sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
|
||||
const completed = planProgress.filter((p) => p.completedAt && getPlan(p.planId)).sort((a, b) => b.completedAt - a.completedAt);
|
||||
const streak = computeStreak(readingActivity);
|
||||
const editingPlan = builderMode && builderMode !== 'new' ? customPlans.find((p) => p.id === builderMode) : null;
|
||||
|
||||
const categoryOrder = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical'];
|
||||
const builtinCategories = categoryOrder.filter((c) => READING_PLANS.some((p) => p.category === c));
|
||||
const hero = active[0];
|
||||
const heroNext = hero ? nextOf(hero) : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold">Reading Plans</h1>
|
||||
{streak.current > 0 && (
|
||||
<p className="mt-1 text-sm text-amber-300">
|
||||
🔥 {streak.current} day{streak.current !== 1 ? 's' : ''} in a row
|
||||
{streak.longest > streak.current && ` · best: ${streak.longest}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-slate-300">
|
||||
Read in{' '}
|
||||
<select
|
||||
value={readerTranslation}
|
||||
onChange={(e) => setReaderTranslation(e.target.value)}
|
||||
className="rounded-xl border border-slate-500 bg-slate-800 px-2 py-1.5 text-sm text-white"
|
||||
>
|
||||
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goHome}
|
||||
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<AuthStatus />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
const catalog = [...customPlans, ...READING_PLANS];
|
||||
const categories = ['All', ...CATEGORY_ORDER.filter((c) => READING_PLANS.some((p) => p.category === c)), ...(customPlans.length ? ['My Plans'] : [])];
|
||||
const shown = catalog.filter((p) => category === 'All' || p.category === category);
|
||||
|
||||
<main className="mx-auto max-w-5xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||
{readingPlanSyncStatus === 'error' && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm">
|
||||
<span className="text-rose-700">
|
||||
⚠️ Couldn't sync your reading plans to the server — changes here are only saved on this device until this is fixed.
|
||||
{readingPlanSyncError && <span className="block text-xs text-rose-500">{readingPlanSyncError}</span>}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={retryReadingPlanSync}
|
||||
className="shrink-0 rounded-xl border border-rose-300 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{activeProgress.length > 0 && (
|
||||
<TodayPanel
|
||||
activeProgress={activeProgress}
|
||||
onRead={readReadingPlanDay}
|
||||
onListen={listenReadingPlanDay}
|
||||
onStudy={studyReadingPlanDay}
|
||||
studyBuildBusy={studyBuildBusy}
|
||||
onToggleDay={toggleDay}
|
||||
/>
|
||||
)}
|
||||
const cycleTheme = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
|
||||
|
||||
{activeProgress.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900">Continue Reading</h2>
|
||||
<div className="space-y-4">
|
||||
{activeProgress.map((progress) => (
|
||||
<ActivePlanCard
|
||||
key={progress.id}
|
||||
progress={progress}
|
||||
onRead={readReadingPlanDay}
|
||||
onListen={listenReadingPlanDay}
|
||||
onStudy={studyReadingPlanDay}
|
||||
studyBuildBusy={studyBuildBusy}
|
||||
onToggleDay={toggleDay}
|
||||
onRestart={restartPlan}
|
||||
onRemove={removePlanProgress}
|
||||
onRecalibrate={recalibratePace}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
const actions = {
|
||||
defaultTranslation: readerTranslation,
|
||||
setTranslation: setPlanTranslation,
|
||||
toggleDay,
|
||||
recalibrate: recalibratePace,
|
||||
restart: restartPlan,
|
||||
remove: removePlanProgress,
|
||||
read: openPlanReading,
|
||||
start: startPlan,
|
||||
nextDay: (plan, progress) => nextUnreadDayIndex(plan, progress.completedDays) ?? 0,
|
||||
edit: setBuilderMode,
|
||||
delete: removeCustomPlan,
|
||||
updateCover: (id, image) => updateCustomPlan(id, { image }),
|
||||
};
|
||||
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900">My Plans</h2>
|
||||
{builderMode === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBuilderMode('new')}
|
||||
className="rounded-xl bg-rose-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-rose-500"
|
||||
>
|
||||
+ New Plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{builderMode !== null && (
|
||||
<div className="mb-4">
|
||||
<CustomPlanBuilder
|
||||
key={builderMode}
|
||||
initialValues={editingPlan}
|
||||
onSubmit={(values) => {
|
||||
const detailPlan = detail ? getPlan(detail.planId) : null;
|
||||
const detailProgress = detail?.progressId ? planProgress.find((p) => p.id === detail.progressId) : null;
|
||||
const detailActive = detail && !detail.progressId ? active.find((p) => p.planId === detail.planId) : null;
|
||||
|
||||
const submitBuilder = (values) => {
|
||||
if (editingPlan) {
|
||||
const booksChanged = JSON.stringify(values.books) !== JSON.stringify(editingPlan.books);
|
||||
const daysChanged = values.days !== editingPlan.days;
|
||||
@@ -178,643 +97,149 @@ export default function ReadingPlansPage() {
|
||||
addCustomPlan(values);
|
||||
}
|
||||
setBuilderMode(null);
|
||||
}}
|
||||
onCancel={() => setBuilderMode(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{customPlans.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{customPlans.map((plan) => (
|
||||
<PlanCatalogCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
progressForPlan={planProgress.filter((p) => p.planId === plan.id)}
|
||||
onStart={startPlan}
|
||||
onEdit={(id) => setBuilderMode(id)}
|
||||
onDelete={(id) => { if (window.confirm('Delete this plan? Its progress history will be removed too.')) removeCustomPlan(id); }}
|
||||
onImageChange={(id, image) => updateCustomPlan(id, { image })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : builderMode === null && (
|
||||
<p className="text-sm text-slate-500">You haven't created any plans yet — try "+ New Plan".</p>
|
||||
)}
|
||||
</section>
|
||||
};
|
||||
|
||||
{builtinCategories.map((category) => (
|
||||
<section key={category}>
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900">{category}</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{READING_PLANS.filter((p) => p.category === category).map((plan) => (
|
||||
<PlanCatalogCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
progressForPlan={planProgress.filter((p) => p.planId === plan.id)}
|
||||
onStart={startPlan}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{completedProgress.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900">Completed</h2>
|
||||
<div className="space-y-2">
|
||||
{completedProgress.map((progress) => {
|
||||
const plan = getPlan(progress.planId);
|
||||
if (!plan) return null;
|
||||
return (
|
||||
<div
|
||||
key={progress.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-3 shadow-panel"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{plan.icon ?? '✓'} {plan.title}</p>
|
||||
<p className="text-xs text-slate-500">Completed {formatRelativeDate(progress.completedAt)}</p>
|
||||
<div className="min-h-screen bg-[var(--r-bg)] pb-16 text-[var(--r-text)]" style={themeVars(readerTheme)}>
|
||||
<div className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
|
||||
<div className="mx-auto flex h-12 max-w-3xl items-center gap-1 px-2">
|
||||
<button type="button" onClick={goHome} aria-label="Back to studies" className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
|
||||
<h1 className="mx-auto text-sm font-medium">Reading plans</h1>
|
||||
{streak.current > 0 && <span className="text-xs text-[var(--r-accent)]">{streak.current} day streak</span>}
|
||||
<button type="button" onClick={cycleTheme} className="ml-2 rounded-lg px-2 py-1.5 text-xs text-[var(--r-sub)] hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => restartPlan(progress.id)}
|
||||
className="font-semibold text-slate-700 hover:underline"
|
||||
>
|
||||
Start again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm('Remove this from your history?')) removePlanProgress(progress.id); }}
|
||||
className="text-rose-500 hover:text-rose-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main className="mx-auto max-w-3xl px-4 pt-5 font-sans">
|
||||
{readingPlanSyncStatus === 'error' && (
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-300 px-4 py-3 text-sm">
|
||||
<span className="text-red-700">
|
||||
Couldn’t sync your reading plans to the server. Changes are only saved on this device until this is fixed.
|
||||
{readingPlanSyncError && <span className="block text-xs">{readingPlanSyncError}</span>}
|
||||
</span>
|
||||
<button type="button" onClick={retryReadingPlanSync} className="shrink-0 rounded-lg border border-red-300 px-3 py-1.5 font-medium text-red-700 hover:bg-red-50">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hero && heroNext && (
|
||||
<section className="rounded-2xl bg-[var(--r-accent)] p-5 text-[var(--r-on)]">
|
||||
<p className="text-xs opacity-90">Today · {heroNext.plan.title} · Day {heroNext.dayIndex + 1}</p>
|
||||
<p className="mb-4 mt-1 font-serif text-2xl">{formatDayReadings(heroNext.refs)}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" onClick={() => openPlanReading(hero.id, heroNext.dayIndex)}
|
||||
className="rounded-full bg-[var(--r-on)] px-5 py-2 text-sm font-medium text-[var(--r-accent)]">Continue reading</button>
|
||||
<button type="button" disabled={studyBuildBusy} onClick={() => studyReadingPlanDay(heroNext.refs, `${heroNext.plan.title} — ${formatDayReadings(heroNext.refs)}`)}
|
||||
className="rounded-full border border-current px-4 py-2 text-sm opacity-90 hover:opacity-100 disabled:opacity-50">{studyBuildBusy ? 'Building…' : 'Study this day'}</button>
|
||||
<span className="ml-auto rounded-full border border-current px-2 py-0.5 text-[11px] opacity-90">{hero.translation ?? readerTranslation}</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{active.length > 0 && (
|
||||
<>
|
||||
<h2 className={sectionTitle}>In progress</h2>
|
||||
<div className="space-y-2">
|
||||
{active.map((p) => {
|
||||
const next = nextOf(p);
|
||||
const plan = getPlan(p.planId);
|
||||
const pct = Math.round((p.completedDays.length / plan.days) * 100);
|
||||
const behind = daysBehindPace(plan, p);
|
||||
return (
|
||||
<div key={p.id} className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4">
|
||||
<button type="button" onClick={() => setDetail({ planId: p.planId, progressId: p.id })} className="block w-full text-left">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{plan.icon ? `${plan.icon} ` : ''}{plan.title}</span>
|
||||
<span className={pill}>{p.translation ?? readerTranslation}</span>
|
||||
</div>
|
||||
<p className="mb-2 mt-0.5 text-xs text-[var(--r-sub)]">
|
||||
Day {p.completedDays.length} of {plan.days} done{next ? ` · next: ${formatDayReadings(next.refs)}` : ''}{behind >= 2 ? ` · ${behind} days behind` : ''}
|
||||
</p>
|
||||
<ProgressBar pct={pct} />
|
||||
</button>
|
||||
{next && (
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-xs text-[var(--r-sub)]">{pct}%</span>
|
||||
<button type="button" onClick={() => openPlanReading(p.id, next.dayIndex)}
|
||||
className="rounded-full border border-[var(--r-line)] px-4 py-1.5 text-xs font-medium hover:bg-black/5">Continue</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A single combined "what's left to read today, across every active plan" list —
|
||||
// the fast-glance view; ActivePlanCard below still has full detail/expand/restart.
|
||||
function TodayPanel({ activeProgress, onRead, onListen, onStudy, studyBuildBusy, onToggleDay }) {
|
||||
const rows = activeProgress
|
||||
.map((progress) => {
|
||||
const plan = getPlan(progress.planId);
|
||||
if (!plan) return null;
|
||||
const schedule = getPlanSchedule(progress.planId);
|
||||
const dayIndex = nextUnreadDayIndex(plan, progress.completedDays);
|
||||
if (dayIndex == null) return null;
|
||||
const refs = schedule[dayIndex];
|
||||
return { progress, plan, dayIndex, refs, ref: firstRefOfDay(refs) };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-3xl border border-indigo-200 bg-indigo-50 p-5 shadow-panel">
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900">📅 Today</h2>
|
||||
<div className="space-y-1.5">
|
||||
{rows.map(({ progress, plan, dayIndex, refs, ref }) => (
|
||||
<div key={progress.id} className="flex flex-wrap items-center gap-3 rounded-2xl bg-white px-4 py-2.5 shadow-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={false}
|
||||
onChange={() => onToggleDay(progress.id, dayIndex, plan.days)}
|
||||
className="h-4 w-4 shrink-0 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="shrink-0 text-lg leading-none">{plan.icon ?? '📖'}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-slate-900">{plan.title}</p>
|
||||
<p className="truncate text-xs text-slate-500">{formatDayReadings(refs)}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onListen(ref.book, ref.chapter)}
|
||||
title="Listen to today's reading"
|
||||
className="rounded-xl border border-indigo-200 px-2.5 py-1.5 text-sm text-indigo-700 hover:bg-indigo-100"
|
||||
>
|
||||
🎧
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={studyBuildBusy}
|
||||
onClick={() => onStudy(refs, `${plan.title} — ${formatDayReadings(refs)}`)}
|
||||
title="Start a study project from this passage"
|
||||
className="rounded-xl border border-indigo-200 px-2.5 py-1.5 text-sm text-indigo-700 hover:bg-indigo-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRead(ref.book, ref.chapter)}
|
||||
className="rounded-xl bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500"
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCover({ plan }) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const theme = themeForCategory(plan.category);
|
||||
const showImage = !!plan.image && !imgFailed;
|
||||
return (
|
||||
<div className={`relative flex h-32 items-center justify-center overflow-hidden bg-gradient-to-br ${theme.gradient}`}>
|
||||
{showImage && (
|
||||
<img
|
||||
src={plan.image}
|
||||
alt=""
|
||||
onError={() => setImgFailed(true)}
|
||||
style={{ objectPosition: plan.imageFocus ?? 'center' }}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{!showImage && <span className="text-4xl drop-shadow">{plan.icon ?? '📖'}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivePlanCard({ progress, onRead, onListen, onStudy, studyBuildBusy, onToggleDay, onRestart, onRemove, onRecalibrate }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const plan = getPlan(progress.planId);
|
||||
if (!plan) return null;
|
||||
|
||||
const theme = themeForCategory(plan.category);
|
||||
const schedule = getPlanSchedule(progress.planId);
|
||||
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
|
||||
const nextDay = nextUnreadDayIndex(plan, progress.completedDays);
|
||||
const nextRefs = nextDay != null ? schedule[nextDay] : null;
|
||||
const nextFirstRef = nextRefs ? firstRefOfDay(nextRefs) : null;
|
||||
const behind = daysBehindPace(plan, progress);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-3xl border border-emerald-200 bg-emerald-50 shadow-panel">
|
||||
<div className={`h-1.5 w-full bg-gradient-to-r ${theme.gradient}`} />
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className={`text-xs font-semibold uppercase tracking-widest ${theme.text}`}>{plan.category}</p>
|
||||
<h3 className="mt-1 text-base font-semibold text-slate-900">{plan.icon ?? '📖'} {plan.title}</h3>
|
||||
<p className="mt-0.5 text-sm text-slate-600">
|
||||
{progress.completedDays.length} / {plan.days} days
|
||||
{nextRefs && <> · Next: Day {nextDay + 1} — {formatDayReadings(nextRefs)}</>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{nextFirstRef && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onListen(nextFirstRef.book, nextFirstRef.chapter)}
|
||||
title="Listen to today's reading"
|
||||
className="rounded-xl border border-emerald-300 px-2.5 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
|
||||
>
|
||||
🎧
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={studyBuildBusy}
|
||||
onClick={() => onStudy(nextRefs, `${plan.title} — ${formatDayReadings(nextRefs)}`)}
|
||||
title="Start a study project from this passage"
|
||||
className="rounded-xl border border-emerald-300 px-2.5 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRead(nextFirstRef.book, nextFirstRef.chapter)}
|
||||
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
|
||||
>
|
||||
{expanded ? 'Hide days' : 'View days'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-emerald-200">
|
||||
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
|
||||
<div className={`${sectionTitle} flex items-center justify-between`}>
|
||||
<span>Browse plans</span>
|
||||
<button type="button" onClick={() => setBuilderMode('new')} className="rounded-full border border-[var(--r-line)] px-3 py-1 text-[11px] normal-case tracking-normal text-[var(--r-text)] hover:bg-black/5">+ New plan</button>
|
||||
</div>
|
||||
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
|
||||
|
||||
{behind >= 2 && (
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs">
|
||||
<span className="text-amber-700">📅 {behind} days behind pace</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRecalibrate(progress.id)}
|
||||
className="font-semibold text-amber-800 hover:underline"
|
||||
>
|
||||
Catch me up
|
||||
</button>
|
||||
<div className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
|
||||
{categories.map((c) => (
|
||||
<button key={c} type="button" onClick={() => setCategory(c)}
|
||||
className={`shrink-0 rounded-full border px-3 py-1 text-xs ${category === c ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{c}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 max-h-72 overflow-y-auto rounded-2xl border border-emerald-100 bg-white p-2">
|
||||
{schedule.map((dayRefs, i) => {
|
||||
const done = progress.completedDays.includes(i);
|
||||
const ref = firstRefOfDay(dayRefs);
|
||||
<div className="space-y-2">
|
||||
{shown.map((plan) => {
|
||||
const mine = planProgress.filter((p) => p.planId === plan.id);
|
||||
const running = mine.find((p) => !p.completedAt);
|
||||
const done = mine.filter((p) => p.completedAt).length;
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-slate-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={done}
|
||||
onChange={() => onToggleDay(progress.id, i, plan.days)}
|
||||
className="h-4 w-4 shrink-0 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className={done ? 'flex-1 text-slate-400 line-through' : 'flex-1 text-slate-700'}>
|
||||
Day {i + 1} — {formatDayReadings(dayRefs)}
|
||||
<button key={plan.id} type="button" onClick={() => setDetail({ planId: plan.id })}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-[var(--r-line)] p-3 text-left hover:bg-black/5">
|
||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-[var(--r-bar)] text-2xl">
|
||||
{plan.image
|
||||
? <img src={plan.image} alt="" onError={(e) => { e.currentTarget.replaceWith(document.createTextNode(plan.icon ?? '📖')); }} className="h-full w-full object-cover" />
|
||||
: (plan.icon ?? '📖')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRead(ref.book, ref.chapter)}
|
||||
className="shrink-0 text-xs font-semibold text-sky-600 hover:underline"
|
||||
>
|
||||
Read
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{plan.title}</span>
|
||||
<span className="block truncate text-xs text-[var(--r-sub)]">{plan.days} days · {plan.description || 'A custom reading plan.'}</span>
|
||||
</span>
|
||||
{running ? <span className={pill}>In progress</span> : done > 0 ? <span className={pill}>Done {done}×</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{completed.length > 0 && (
|
||||
<>
|
||||
<h2 className={sectionTitle}>Completed</h2>
|
||||
<div className="space-y-2">
|
||||
{completed.map((p) => {
|
||||
const plan = getPlan(p.planId);
|
||||
return (
|
||||
<div key={p.id} className="flex items-center justify-between gap-3 rounded-2xl border border-[var(--r-line)] px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{plan.icon ?? '✓'} {plan.title}</p>
|
||||
<p className="text-xs text-[var(--r-sub)]">Completed {formatRelativeDate(p.completedAt)}{p.translation ? ` · ${p.translation}` : ''}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs">
|
||||
<button type="button" onClick={() => restartPlan(p.id)} className="font-medium hover:underline">Start again</button>
|
||||
<button type="button" onClick={() => { if (window.confirm('Remove this from your history?')) removePlanProgress(p.id); }} className="text-red-600 hover:underline">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{detailPlan && (
|
||||
<PlanDetailSheet
|
||||
plan={detailPlan} progress={detailProgress} active={detailActive}
|
||||
completedCount={planProgress.filter((p) => p.planId === detailPlan.id && p.completedAt).length}
|
||||
onClose={() => setDetail(null)} actions={actions}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-4 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm('Restart this plan from day 1?')) onRestart(progress.id); }}
|
||||
className="font-semibold text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm('Remove this plan?')) onRemove(progress.id); }}
|
||||
className="font-semibold text-rose-500 hover:text-rose-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Sheet open={builderMode !== null} onClose={() => setBuilderMode(null)} title={editingPlan ? 'Edit reading plan' : 'New reading plan'}>
|
||||
{builderMode !== null && (
|
||||
<CustomPlanBuilder key={builderMode} initialValues={editingPlan} onSubmit={submitBuilder} onCancel={() => setBuilderMode(null)} />
|
||||
)}
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCatalogCard({ plan, progressForPlan, onStart, onEdit, onDelete, onImageChange }) {
|
||||
const active = progressForPlan.find((p) => !p.completedAt);
|
||||
const completedCount = progressForPlan.filter((p) => p.completedAt).length;
|
||||
const theme = themeForCategory(plan.category);
|
||||
const fileInputRef = useRef(null);
|
||||
const [imageError, setImageError] = useState('');
|
||||
|
||||
const pickImage = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ''; // lets the same file be re-picked later
|
||||
if (!file) return;
|
||||
try {
|
||||
setImageError('');
|
||||
onImageChange(plan.id, await resizeImageToDataUrl(file));
|
||||
} catch {
|
||||
setImageError("Couldn't use that image.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-panel transition hover:shadow-lg">
|
||||
<PlanCover plan={plan} />
|
||||
<div className="flex flex-col gap-2 p-5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-900">{plan.title}</h3>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{onEdit && (
|
||||
<button type="button" onClick={() => onEdit(plan.id)} title="Edit plan" className="text-slate-300 transition hover:text-slate-600">
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(plan.id)}
|
||||
title="Delete plan"
|
||||
className="text-slate-300 transition hover:text-rose-500"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex-1 text-xs text-slate-500">{plan.description || 'A custom reading plan.'}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${theme.chip}`}>{plan.days} days</span>
|
||||
{completedCount > 0 && <span className="text-xs font-semibold text-emerald-600">✓ Completed {completedCount}×</span>}
|
||||
</div>
|
||||
{onImageChange && (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} className="font-semibold text-slate-500 hover:text-slate-700">
|
||||
{plan.image ? 'Change cover' : '+ Add cover photo'}
|
||||
</button>
|
||||
{plan.image && (
|
||||
<button type="button" onClick={() => onImageChange(plan.id, null)} className="text-slate-400 hover:text-rose-500">
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
|
||||
</div>
|
||||
)}
|
||||
{imageError && <p className="text-xs text-rose-500">{imageError}</p>}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!active}
|
||||
onClick={() => onStart(plan.id)}
|
||||
className="mt-1 rounded-xl bg-slate-900 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{active ? 'In progress' : completedCount > 0 ? 'Start again' : 'Start plan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
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],
|
||||
];
|
||||
|
||||
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 (
|
||||
<div className="rounded-2xl border border-rose-200 bg-white p-5 shadow-panel">
|
||||
<h3 className="mb-3 text-sm font-semibold text-slate-900">{isEditing ? 'Edit reading plan' : 'New reading plan'}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-sm text-slate-600">
|
||||
Title
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Advent Readings"
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-200"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-slate-600">
|
||||
Days
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-200"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="mt-3 block text-sm text-slate-600">
|
||||
Description <span className="text-slate-400">(optional)</span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-200"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-3">
|
||||
<span className="text-sm text-slate-600">Cover photo <span className="text-slate-400">(optional)</span></span>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
{image ? (
|
||||
<img src={image} alt="" className="h-16 w-28 rounded-lg object-cover" />
|
||||
) : (
|
||||
<div className="flex h-16 w-28 items-center justify-center rounded-lg bg-slate-100 text-2xl">🖼️</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
{image ? 'Change photo' : 'Choose photo'}
|
||||
</button>
|
||||
{image && (
|
||||
<button type="button" onClick={() => setImage(null)} className="text-sm text-slate-400 hover:text-rose-500">
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
|
||||
</div>
|
||||
{imageError && <p className="mt-1 text-xs text-rose-500">{imageError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-700">Books</span>
|
||||
{BOOK_PRESETS.map(([label, list]) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => applyPreset(list)}
|
||||
className="rounded-full border border-slate-300 px-2.5 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-slate-400">
|
||||
Check a book to include the whole thing, or narrow it to a chapter range with the fields that appear.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="max-h-56 overflow-y-auto rounded-xl border border-slate-200 p-2">
|
||||
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-slate-400">Old Testament</p>
|
||||
{bookOptions.filter((b) => OT_BOOKS.includes(b.abbrev)).map((b) => (
|
||||
<BookRow
|
||||
key={b.abbrev}
|
||||
book={b}
|
||||
checked={books.includes(b.abbrev)}
|
||||
range={chapterRanges[b.abbrev]}
|
||||
onToggle={() => toggleBook(b.abbrev)}
|
||||
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto rounded-xl border border-slate-200 p-2">
|
||||
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-slate-400">New Testament</p>
|
||||
{bookOptions.filter((b) => NT_BOOKS.includes(b.abbrev)).map((b) => (
|
||||
<BookRow
|
||||
key={b.abbrev}
|
||||
book={b}
|
||||
checked={books.includes(b.abbrev)}
|
||||
range={chapterRanges[b.abbrev]}
|
||||
onToggle={() => toggleBook(b.abbrev)}
|
||||
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
{books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters
|
||||
{days > 0 && totalChapters > 0 && ` · ~${(totalChapters / days).toFixed(1)} ch/day`}
|
||||
</p>
|
||||
{isEditing && (
|
||||
books.join() !== initialValues.books.join()
|
||||
|| Number(days) !== initialValues.days
|
||||
|| JSON.stringify(chapterRanges) !== JSON.stringify(initialValues.chapterRanges ?? {})
|
||||
) && (
|
||||
<p className="mt-2 text-xs text-amber-600">
|
||||
Changing the books, chapters, or day count restarts progress for any run of this plan already in your list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canCreate}
|
||||
onClick={() => onSubmit({ title, description, days, books, chapterRanges, image })}
|
||||
className="rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isEditing ? 'Save changes' : 'Create plan'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One book row in the custom-plan builder: a checkbox, and — once checked — a
|
||||
// "ch. X–Y" chapter-range pair that defaults to the whole book.
|
||||
function BookRow({ book, checked, range, onToggle, onRangeChange }) {
|
||||
const total = CHAPTER_COUNTS[book.abbrev] ?? 1;
|
||||
return (
|
||||
<label className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-slate-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onToggle}
|
||||
className="h-4 w-4 shrink-0 rounded border-slate-300 text-rose-600 focus:ring-rose-500"
|
||||
/>
|
||||
<span className="flex-1">{book.name}</span>
|
||||
{checked && total > 1 && (
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-slate-500" onClick={(e) => e.preventDefault()}>
|
||||
ch.
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={total}
|
||||
value={range?.start ?? 1}
|
||||
onChange={(e) => onRangeChange('start', e.target.value)}
|
||||
className="w-11 rounded border border-slate-300 px-1 py-0.5 text-center"
|
||||
/>
|
||||
–
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={total}
|
||||
value={range?.end ?? total}
|
||||
onChange={(e) => onRangeChange('end', e.target.value)}
|
||||
className="w-11 rounded border border-slate-300 px-1 py-0.5 text-center"
|
||||
/>
|
||||
<span className="text-slate-400">/{total}</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import { getPlanSchedule, formatDayReadings } from '../utils/readingPlans.js';
|
||||
|
||||
const fn = () => vi.fn();
|
||||
const ctx = {};
|
||||
vi.mock('../context/AppContext.js', () => ({ useApp: () => ctx }));
|
||||
import ReadingPlansPage from './ReadingPlansPage.jsx';
|
||||
|
||||
const record = (over = {}) => ({
|
||||
id: 'p1', planId: 'gospels-30', startedAt: Date.now(), completedDays: [0, 1], chaptersRead: {}, completedAt: null,
|
||||
lastActivityAt: Date.now(), translation: 'NLT', ...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(ctx, {
|
||||
goHome: fn(), planProgress: [], customPlans: [], readingActivity: [],
|
||||
startPlan: fn(), toggleDay: fn(), restartPlan: fn(), removePlanProgress: fn(), recalibratePace: fn(), setPlanTranslation: fn(),
|
||||
addCustomPlan: fn(), removeCustomPlan: fn(), updateCustomPlan: fn(),
|
||||
openPlanReading: fn(), studyReadingPlanDay: fn(), studyBuildBusy: false,
|
||||
readingPlanSyncStatus: '', readingPlanSyncError: '', retryReadingPlanSync: fn(),
|
||||
readerTheme: 'paper', setReaderTheme: fn(), readerTranslation: 'BSB',
|
||||
});
|
||||
});
|
||||
|
||||
test('with no plans, the catalog is shown and there is no Continue card', () => {
|
||||
render(<ReadingPlansPage />);
|
||||
expect(screen.queryByText('Continue reading')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /The Gospels in 30 Days/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Continue reading opens the plan at its next unread day, in the plan translation', () => {
|
||||
ctx.planProgress = [record()];
|
||||
render(<ReadingPlansPage />);
|
||||
const day2 = getPlanSchedule('gospels-30')[2];
|
||||
expect(screen.getAllByText(new RegExp(formatDayReadings(day2)))[0]).toBeTruthy();
|
||||
expect(screen.getAllByText('NLT').length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByText('Continue reading'));
|
||||
expect(ctx.openPlanReading).toHaveBeenCalledWith('p1', 2);
|
||||
});
|
||||
|
||||
test('starting a catalog plan passes the chosen translation', () => {
|
||||
render(<ReadingPlansPage />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /The Gospels in 30 Days/ }));
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'CSB' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start plan' }));
|
||||
expect(ctx.startPlan).toHaveBeenCalledWith('gospels-30', 'CSB');
|
||||
});
|
||||
|
||||
test('every reader translation can be chosen for a plan', () => {
|
||||
render(<ReadingPlansPage />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /The Gospels in 30 Days/ }));
|
||||
const options = within(screen.getByRole('combobox')).getAllByRole('option').map((o) => o.value);
|
||||
expect(options).toEqual(expect.arrayContaining(['BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET']));
|
||||
});
|
||||
|
||||
test('an in-progress plan: change its translation, jump into a specific day', () => {
|
||||
ctx.planProgress = [record()];
|
||||
render(<ReadingPlansPage />);
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /The Gospels in 30 Days/ })[0]);
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'ESV' } });
|
||||
expect(ctx.setPlanTranslation).toHaveBeenCalledWith('p1', 'ESV');
|
||||
fireEvent.click(screen.getAllByText('Read')[3]); // "Read" on Day 4
|
||||
expect(ctx.openPlanReading).toHaveBeenCalledWith('p1', 3);
|
||||
});
|
||||
|
||||
test('categories filter the catalog', () => {
|
||||
render(<ReadingPlansPage />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Old Testament' }));
|
||||
expect(screen.queryByRole('button', { name: /The Gospels in 30 Days/ })).toBeNull();
|
||||
});
|
||||
Reference in new issue
Block a user