Files
study-bible-project/src/pages/ReadingPlansPage.jsx
T
nmemmertandClaude Sonnet 5.5 7fc5e4b534
Build and Push Docker Image / build-and-push (push) Successful in 4m30s
Match the browser/OS chrome to the theme and clear the status bar
The app now sets theme-color, color-scheme and the page background from the chosen theme,
so the iPad status-bar area no longer shows a light band over a dark theme. Every top bar
leaves room for the status bar via a safe-top class (replacing a rule that padded every
<header>). Install defaults match the paper theme.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:24:58 -04:00

246 lines
14 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.
// 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 <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, 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 (
<div className="min-h-screen bg-[var(--r-bg)] pb-16 text-[var(--r-text)]" style={themeVars(readerTheme)}>
<div className="safe-top 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 home" className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></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>
<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-[var(--r-danger,#b42318)]">
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-[var(--r-danger,#b42318)] 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>
</>
)}
<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>
<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>
<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 (
<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>
<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-[var(--r-danger,#b42318)] 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}
/>
)}
<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>
);
}