import { useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import AuthStatus from '../components/AuthStatus.jsx';
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../utils/bibleUtils.jsx';
import {
READING_PLANS,
getPlan,
getPlanSchedule,
formatDayReadings,
firstRefOfDay,
nextUnreadDayIndex,
themeForCategory,
ALL_BOOKS,
OT_BOOKS,
NT_BOOKS,
GOSPELS,
PENTATEUCH,
WISDOM_BOOKS,
} from '../utils/readingPlans.js';
export default function ReadingPlansPage() {
const {
goHome,
planProgress,
customPlans,
startPlan,
toggleDay,
restartPlan,
removePlanProgress,
addCustomPlan,
removeCustomPlan,
updateCustomPlan,
readReadingPlanDay,
} = useApp();
const [showBuilder, setShowBuilder] = useState(false);
const activeProgress = planProgress.filter((p) => !p.completedAt);
const completedProgress = planProgress
.filter((p) => p.completedAt)
.sort((a, b) => b.completedAt - a.completedAt);
const categoryOrder = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical'];
const builtinCategories = categoryOrder.filter((c) => READING_PLANS.some((p) => p.category === c));
return (
Bible Study Project
Reading Plans
{activeProgress.length > 0 && (
Continue Reading
{activeProgress.map((progress) => (
))}
)}
My Plans
{!showBuilder && (
)}
{showBuilder && (
{ addCustomPlan(input); setShowBuilder(false); }}
onCancel={() => setShowBuilder(false)}
/>
)}
{customPlans.length > 0 ? (
{customPlans.map((plan) => (
p.planId === plan.id)}
onStart={startPlan}
onDelete={(id) => { if (window.confirm('Delete this plan? Its progress history will be removed too.')) removeCustomPlan(id); }}
onImageChange={(id, image) => updateCustomPlan(id, { image })}
/>
))}
) : !showBuilder && (
You haven't created any plans yet โ try "+ New Plan".
)}
{builtinCategories.map((category) => (
{category}
{READING_PLANS.filter((p) => p.category === category).map((plan) => (
p.planId === plan.id)}
onStart={startPlan}
/>
))}
))}
{completedProgress.length > 0 && (
Completed
{completedProgress.map((progress) => {
const plan = getPlan(progress.planId);
if (!plan) return null;
return (
{plan.icon ?? 'โ'} {plan.title}
Completed {formatRelativeDate(progress.completedAt)}
);
})}
)}
);
}
function PlanCover({ plan }) {
const [imgFailed, setImgFailed] = useState(false);
const theme = themeForCategory(plan.category);
const showImage = !!plan.image && !imgFailed;
return (
{showImage && (

setImgFailed(true)}
style={{ objectPosition: plan.imageFocus ?? 'center' }}
className="absolute inset-0 h-full w-full object-cover"
/>
)}
{!showImage &&
{plan.icon ?? '๐'}}
);
}
function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove }) {
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;
return (
{plan.category}
{plan.icon ?? '๐'} {plan.title}
{progress.completedDays.length} / {plan.days} days
{nextRefs && <> ยท Next: Day {nextDay + 1} โ {formatDayReadings(nextRefs)}>}
{nextFirstRef && (
)}
{pct}%
{expanded && (
{schedule.map((dayRefs, i) => {
const done = progress.completedDays.includes(i);
const ref = firstRefOfDay(dayRefs);
return (
onToggleDay(progress.id, i, plan.days)}
className="h-4 w-4 shrink-0 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
/>
Day {i + 1} โ {formatDayReadings(dayRefs)}
);
})}
)}
);
}
function PlanCatalogCard({ plan, progressForPlan, onStart, 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 (
{plan.title}
{onDelete && (
)}
{plan.description || 'A custom reading plan.'}
{plan.days} days
{completedCount > 0 && โ Completed {completedCount}ร}
{onImageChange && (
{plan.image && (
)}
)}
{imageError &&
{imageError}
}
);
}
// 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({ onCreate, onCancel }) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [days, setDays] = useState(30);
const [books, setBooks] = useState([]);
const [image, setImage] = useState(null);
const [imageError, setImageError] = useState('');
const fileInputRef = useRef(null);
const toggleBook = (abbrev) => {
setBooks((prev) => (prev.includes(abbrev) ? prev.filter((b) => b !== abbrev) : [...prev, abbrev]));
};
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 + (CHAPTER_COUNTS[b] ?? 0), 0);
const canCreate = title.trim().length > 0 && books.length > 0 && days >= 1;
return (
New reading plan
Cover photo (optional)
{image ? (

) : (
๐ผ๏ธ
)}
{image && (
)}
{imageError &&
{imageError}
}
Books
{BOOK_PRESETS.map(([label, list]) => (
))}
Old Testament
{bookOptions.filter((b) => OT_BOOKS.includes(b.abbrev)).map((b) => (
))}
New Testament
{bookOptions.filter((b) => NT_BOOKS.includes(b.abbrev)).map((b) => (
))}
{books.length} book{books.length !== 1 ? 's' : ''} selected ยท {totalChapters} chapters
{days > 0 && totalChapters > 0 && ` ยท ~${(totalChapters / days).toFixed(1)} ch/day`}
);
}