// 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 { 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, 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
;
}
// 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, setPlanTranslation,
addCustomPlan, removeCustomPlan, updateCustomPlan,
openPlanReading, studyReadingPlanDay, studyBuildBusy,
readingPlanSyncStatus, readingPlanSyncError, retryReadingPlanSync,
readerTheme, setReaderTheme, readerTranslation, planTranslationPref,
} = useApp();
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 hero = active[0];
const heroNext = hero ? nextOf(hero) : null;
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);
const cycleTheme = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
const actions = {
defaultTranslation: planTranslationPref && planTranslationPref !== 'same' ? planTranslationPref : 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 }),
};
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;
const rangesChanged = JSON.stringify(values.chapterRanges ?? {}) !== JSON.stringify(editingPlan.chapterRanges ?? {});
updateCustomPlan(editingPlan.id, {
title: values.title.trim(),
description: values.description.trim(),
days: Math.max(1, Math.round(values.days)),
books: values.books,
chapterRanges: values.chapterRanges && Object.keys(values.chapterRanges).length > 0 ? values.chapterRanges : undefined,
image: values.image || undefined,
}, { resetProgress: booksChanged || daysChanged || rangesChanged });
} else {
addCustomPlan(values);
}
setBuilderMode(null);
};
return (
Home
Reading plans
{streak.current > 0 && {streak.current} day streak }
{READER_THEMES[readerTheme]?.label}
{readingPlanSyncStatus === 'error' && (
Couldn’t sync your reading plans to the server. Changes are only saved on this device until this is fixed.
{readingPlanSyncError && {readingPlanSyncError} }
Retry
)}
{hero && heroNext && (
Today · {heroNext.plan.title} · Day {heroNext.dayIndex + 1}
{formatDayReadings(heroNext.refs)}
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
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'}
{hero.translation ?? readerTranslation}
)}
{active.length > 0 && (
<>
In progress
{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 (
setDetail({ planId: p.planId, progressId: p.id })} className="block w-full text-left">
{plan.icon ? `${plan.icon} ` : ''}{plan.title}
{p.translation ?? readerTranslation}
Day {p.completedDays.length} of {plan.days} done{next ? ` · next: ${formatDayReadings(next.refs)}` : ''}{behind >= 2 ? ` · ${behind} days behind` : ''}
{next && (
{pct}%
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
)}
);
})}
>
)}
Browse plans
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
{categories.map((c) => (
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}
))}
{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 (
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">
{plan.image
? { e.currentTarget.replaceWith(document.createTextNode(plan.icon ?? '📖')); }} className="h-full w-full object-cover" />
: (plan.icon ?? '📖')}
{plan.title}
{plan.days} days · {plan.description || 'A custom reading plan.'}
{running ? In progress : done > 0 ? Done {done}× : null}
);
})}
{completed.length > 0 && (
<>
Completed
{completed.map((p) => {
const plan = getPlan(p.planId);
return (
{plan.icon ?? '✓'} {plan.title}
Completed {formatRelativeDate(p.completedAt)}{p.translation ? ` · ${p.translation}` : ''}
restartPlan(p.id)} className="font-medium hover:underline">Start again
{ if (window.confirm('Remove this from your history?')) removePlanProgress(p.id); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove
);
})}
>
)}
{detailPlan && (
p.planId === detailPlan.id && p.completedAt).length}
onClose={() => setDetail(null)} actions={actions}
/>
)}
setBuilderMode(null)} title={editingPlan ? 'Edit reading plan' : 'New reading plan'}>
{builderMode !== null && (
setBuilderMode(null)} />
)}
);
}