Files
study-bible-project/src/pages/ReadingPlansPage.jsx
T
nmemmertandClaude Sonnet 5 600b3d3ab9 Add reading plans: catalog, progress tracking, and custom plans
Replaces the old single-book reading goal with a full Reading Plans page:
9 built-in plans (Whole Bible, OT/NT, topical) with cover photos and
color-themed cards, day-by-day checklists synced to the server per user,
inline "mark today read" in the Bible reader, and user-created plans with
their own book picker and an optional uploaded cover photo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:51:28 -04:00

558 lines
22 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<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>
</div>
<div className="flex items-center gap-3">
<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>
<main className="mx-auto max-w-5xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
{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}
onToggleDay={toggleDay}
onRestart={restartPlan}
onRemove={removePlanProgress}
/>
))}
</div>
</section>
)}
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900">My Plans</h2>
{!showBuilder && (
<button
type="button"
onClick={() => setShowBuilder(true)}
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>
{showBuilder && (
<div className="mb-4">
<CustomPlanBuilder
onCreate={(input) => { addCustomPlan(input); setShowBuilder(false); }}
onCancel={() => setShowBuilder(false)}
/>
</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}
onDelete={(id) => { if (window.confirm('Delete this plan? Its progress history will be removed too.')) removeCustomPlan(id); }}
onImageChange={(id, image) => updateCustomPlan(id, { image })}
/>
))}
</div>
) : !showBuilder && (
<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>
<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>
</div>
);
})}
</div>
</section>
)}
</main>
</div>
);
}
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, 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 (
<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={() => 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>
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
{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);
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)}
</span>
<button
type="button"
onClick={() => onRead(ref.book, ref.chapter)}
className="shrink-0 text-xs font-semibold text-sky-600 hover:underline"
>
Read
</button>
</div>
);
})}
</div>
)}
<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>
</div>
);
}
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 (
<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>
{onDelete && (
<button
type="button"
onClick={() => onDelete(plan.id)}
title="Delete plan"
className="shrink-0 text-slate-300 transition hover:text-rose-500"
>
✕
</button>
)}
</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({ 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 (
<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">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={() => setBooks(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>
<div className="grid gap-3 sm:grid-cols-2">
<div className="max-h-48 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) => (
<label key={b.abbrev} className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-slate-50">
<input
type="checkbox"
checked={books.includes(b.abbrev)}
onChange={() => toggleBook(b.abbrev)}
className="h-4 w-4 rounded border-slate-300 text-rose-600 focus:ring-rose-500"
/>
{b.name}
</label>
))}
</div>
<div className="max-h-48 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) => (
<label key={b.abbrev} className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-slate-50">
<input
type="checkbox"
checked={books.includes(b.abbrev)}
onChange={() => toggleBook(b.abbrev)}
className="h-4 w-4 rounded border-slate-300 text-rose-600 focus:ring-rose-500"
/>
{b.name}
</label>
))}
</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>
</div>
<div className="mt-4 flex items-center gap-3">
<button
type="button"
disabled={!canCreate}
onClick={() => onCreate({ title, description, days, books, 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"
>
Create plan
</button>
<button type="button" onClick={onCancel} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button>
</div>
</div>
);
}