Build and Push Docker Image / build-and-push (push) Successful in 4m30s
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>
246 lines
14 KiB
React
246 lines
14 KiB
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 { 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>
|
||
);
|
||
}
|