Add streaks, today view, custom plan editing/chapter ranges, and ntfy reminders
Build and Push Docker Image / build-and-push (push) Failing after 21s

Reading plans now track a cross-plan activity log for streaks, surface a
combined "today's reading" view on both Home and the Reading Plans page,
and let custom plans be edited after creation (including narrowing a book
to a specific chapter range). The reader auto-marks a plan's day read after
a dwell period, active plans that fall behind their pace get a "catch me
up" banner, and a new Settings section wires up daily reading reminders
via ntfy.sh, checked once a minute by a small server-side scheduler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-23 13:16:27 -04:00
1 parent 600b3d3ab9
commit 1dd6aef6a5
10 files changed
+711 -141

No files matched your search

+234 -52
View File
@@ -10,6 +10,9 @@ import {
firstRefOfDay,
nextUnreadDayIndex,
themeForCategory,
computeStreak,
daysBehindPace,
bookChapterCount,
ALL_BOOKS,
OT_BOOKS,
NT_BOOKS,
@@ -23,21 +26,25 @@ export default function ReadingPlansPage() {
goHome,
planProgress,
customPlans,
readingActivity,
startPlan,
toggleDay,
restartPlan,
removePlanProgress,
recalibratePace,
addCustomPlan,
removeCustomPlan,
updateCustomPlan,
readReadingPlanDay,
} = useApp();
const [showBuilder, setShowBuilder] = useState(false);
const [builderMode, setBuilderMode] = useState(null); // null | 'new' | a custom plan id to edit
const activeProgress = planProgress.filter((p) => !p.completedAt);
const completedProgress = planProgress
.filter((p) => p.completedAt)
.sort((a, b) => b.completedAt - a.completedAt);
const streak = computeStreak(readingActivity);
const editingPlan = builderMode && builderMode !== 'new' ? customPlans.find((p) => p.id === builderMode) : null;
const categoryOrder = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical'];
const builtinCategories = categoryOrder.filter((c) => READING_PLANS.some((p) => p.category === c));
@@ -49,6 +56,12 @@ export default function ReadingPlansPage() {
<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>
{streak.current > 0 && (
<p className="mt-1 text-sm text-amber-300">
🔥 {streak.current} day{streak.current !== 1 ? 's' : ''} in a row
{streak.longest > streak.current && ` · best: ${streak.longest}`}
</p>
)}
</div>
<div className="flex items-center gap-3">
<button
@@ -64,6 +77,10 @@ export default function ReadingPlansPage() {
</header>
<main className="mx-auto max-w-5xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
{activeProgress.length > 0 && (
<TodayPanel activeProgress={activeProgress} onRead={readReadingPlanDay} onToggleDay={toggleDay} />
)}
{activeProgress.length > 0 && (
<section>
<h2 className="mb-3 text-lg font-semibold text-slate-900">Continue Reading</h2>
@@ -76,6 +93,7 @@ export default function ReadingPlansPage() {
onToggleDay={toggleDay}
onRestart={restartPlan}
onRemove={removePlanProgress}
onRecalibrate={recalibratePace}
/>
))}
</div>
@@ -85,21 +103,40 @@ export default function ReadingPlansPage() {
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900">My Plans</h2>
{!showBuilder && (
{builderMode === null && (
<button
type="button"
onClick={() => setShowBuilder(true)}
onClick={() => setBuilderMode('new')}
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 && (
{builderMode !== null && (
<div className="mb-4">
<CustomPlanBuilder
onCreate={(input) => { addCustomPlan(input); setShowBuilder(false); }}
onCancel={() => setShowBuilder(false)}
key={builderMode}
initialValues={editingPlan}
onSubmit={(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);
}}
onCancel={() => setBuilderMode(null)}
/>
</div>
)}
@@ -111,12 +148,13 @@ export default function ReadingPlansPage() {
plan={plan}
progressForPlan={planProgress.filter((p) => p.planId === plan.id)}
onStart={startPlan}
onEdit={(id) => setBuilderMode(id)}
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 && (
) : builderMode === null && (
<p className="text-sm text-slate-500">You haven't created any plans yet — try "+ New Plan".</p>
)}
</section>
@@ -180,6 +218,54 @@ export default function ReadingPlansPage() {
);
}
// A single combined "what's left to read today, across every active plan" list —
// the fast-glance view; ActivePlanCard below still has full detail/expand/restart.
function TodayPanel({ activeProgress, onRead, onToggleDay }) {
const rows = activeProgress
.map((progress) => {
const plan = getPlan(progress.planId);
if (!plan) return null;
const schedule = getPlanSchedule(progress.planId);
const dayIndex = nextUnreadDayIndex(plan, progress.completedDays);
if (dayIndex == null) return null;
const refs = schedule[dayIndex];
return { progress, plan, dayIndex, refs, ref: firstRefOfDay(refs) };
})
.filter(Boolean);
if (rows.length === 0) return null;
return (
<section className="rounded-3xl border border-indigo-200 bg-indigo-50 p-5 shadow-panel">
<h2 className="mb-3 text-lg font-semibold text-slate-900">📅 Today</h2>
<div className="space-y-1.5">
{rows.map(({ progress, plan, dayIndex, refs, ref }) => (
<div key={progress.id} className="flex flex-wrap items-center gap-3 rounded-2xl bg-white px-4 py-2.5 shadow-sm">
<input
type="checkbox"
checked={false}
onChange={() => onToggleDay(progress.id, dayIndex, plan.days)}
className="h-4 w-4 shrink-0 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="shrink-0 text-lg leading-none">{plan.icon ?? '📖'}</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900">{plan.title}</p>
<p className="truncate text-xs text-slate-500">{formatDayReadings(refs)}</p>
</div>
<button
type="button"
onClick={() => onRead(ref.book, ref.chapter)}
className="shrink-0 rounded-xl bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500"
>
Read
</button>
</div>
))}
</div>
</section>
);
}
function PlanCover({ plan }) {
const [imgFailed, setImgFailed] = useState(false);
const theme = themeForCategory(plan.category);
@@ -200,7 +286,7 @@ function PlanCover({ plan }) {
);
}
function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove }) {
function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove, onRecalibrate }) {
const [expanded, setExpanded] = useState(false);
const plan = getPlan(progress.planId);
if (!plan) return null;
@@ -211,6 +297,7 @@ function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove })
const nextDay = nextUnreadDayIndex(plan, progress.completedDays);
const nextRefs = nextDay != null ? schedule[nextDay] : null;
const nextFirstRef = nextRefs ? firstRefOfDay(nextRefs) : null;
const behind = daysBehindPace(plan, progress);
return (
<div className="overflow-hidden rounded-3xl border border-emerald-200 bg-emerald-50 shadow-panel">
@@ -250,6 +337,19 @@ function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove })
</div>
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
{behind >= 2 && (
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs">
<span className="text-amber-700">📅 {behind} days behind pace</span>
<button
type="button"
onClick={() => onRecalibrate(progress.id)}
className="font-semibold text-amber-800 hover:underline"
>
Catch me up
</button>
</div>
)}
{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) => {
@@ -300,7 +400,7 @@ function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove })
);
}
function PlanCatalogCard({ plan, progressForPlan, onStart, onDelete, onImageChange }) {
function PlanCatalogCard({ plan, progressForPlan, onStart, onEdit, onDelete, onImageChange }) {
const active = progressForPlan.find((p) => !p.completedAt);
const completedCount = progressForPlan.filter((p) => p.completedAt).length;
const theme = themeForCategory(plan.category);
@@ -325,16 +425,23 @@ function PlanCatalogCard({ plan, progressForPlan, onStart, onDelete, onImageChan
<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 className="flex shrink-0 items-center gap-2">
{onEdit && (
<button type="button" onClick={() => onEdit(plan.id)} title="Edit plan" className="text-slate-300 transition hover:text-slate-600">
✎
</button>
)}
{onDelete && (
<button
type="button"
onClick={() => onDelete(plan.id)}
title="Delete plan"
className="text-slate-300 transition hover:text-rose-500"
>
✕
</button>
)}
</div>
</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">
@@ -403,17 +510,42 @@ const BOOK_PRESETS = [
['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);
function CustomPlanBuilder({ initialValues, onSubmit, onCancel }) {
const [title, setTitle] = useState(initialValues?.title ?? '');
const [description, setDescription] = useState(initialValues?.description ?? '');
const [days, setDays] = useState(initialValues?.days ?? 30);
const [books, setBooks] = useState(initialValues?.books ?? []);
const [chapterRanges, setChapterRanges] = useState(initialValues?.chapterRanges ?? {});
const [image, setImage] = useState(initialValues?.image ?? null);
const [imageError, setImageError] = useState('');
const fileInputRef = useRef(null);
const isEditing = !!initialValues;
const toggleBook = (abbrev) => {
setBooks((prev) => (prev.includes(abbrev) ? prev.filter((b) => b !== abbrev) : [...prev, abbrev]));
const isOn = books.includes(abbrev);
setBooks((prev) => (isOn ? prev.filter((b) => b !== abbrev) : [...prev, abbrev]));
if (isOn) {
setChapterRanges((prev) => {
if (!(abbrev in prev)) return prev;
const next = { ...prev };
delete next[abbrev];
return next;
});
}
};
const setChapterRange = (abbrev, key, value) => {
const total = CHAPTER_COUNTS[abbrev] ?? 1;
const clamped = Math.min(total, Math.max(1, Math.round(value) || 1));
setChapterRanges((prev) => ({
...prev,
[abbrev]: { start: prev[abbrev]?.start ?? 1, end: prev[abbrev]?.end ?? total, [key]: clamped },
}));
};
const applyPreset = (list) => {
setBooks(list);
setChapterRanges({});
};
const pickImage = async (e) => {
@@ -428,12 +560,12 @@ function CustomPlanBuilder({ onCreate, onCancel }) {
}
};
const totalChapters = books.reduce((sum, b) => sum + (CHAPTER_COUNTS[b] ?? 0), 0);
const canCreate = title.trim().length > 0 && books.length > 0 && days >= 1;
const totalChapters = books.reduce((sum, b) => sum + bookChapterCount(b, chapterRanges), 0);
const canCreate = title.trim().length > 0 && totalChapters > 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>
<h3 className="mb-3 text-sm font-semibold text-slate-900">{isEditing ? 'Edit reading plan' : 'New reading plan'}</h3>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm text-slate-600">
Title
@@ -498,40 +630,41 @@ function CustomPlanBuilder({ onCreate, onCancel }) {
<button
key={label}
type="button"
onClick={() => setBooks(list)}
onClick={() => applyPreset(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>
<p className="mb-2 text-xs text-slate-400">
Check a book to include the whole thing, or narrow it to a chapter range with the fields that appear.
</p>
<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">
<div className="max-h-56 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>
<BookRow
key={b.abbrev}
book={b}
checked={books.includes(b.abbrev)}
range={chapterRanges[b.abbrev]}
onToggle={() => toggleBook(b.abbrev)}
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
/>
))}
</div>
<div className="max-h-48 overflow-y-auto rounded-xl border border-slate-200 p-2">
<div className="max-h-56 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>
<BookRow
key={b.abbrev}
book={b}
checked={books.includes(b.abbrev)}
range={chapterRanges[b.abbrev]}
onToggle={() => toggleBook(b.abbrev)}
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
/>
))}
</div>
</div>
@@ -539,19 +672,68 @@ function CustomPlanBuilder({ onCreate, onCancel }) {
{books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters
{days > 0 && totalChapters > 0 && ` · ~${(totalChapters / days).toFixed(1)} ch/day`}
</p>
{isEditing && (
books.join() !== initialValues.books.join()
|| Number(days) !== initialValues.days
|| JSON.stringify(chapterRanges) !== JSON.stringify(initialValues.chapterRanges ?? {})
) && (
<p className="mt-2 text-xs text-amber-600">
Changing the books, chapters, or day count restarts progress for any run of this plan already in your list.
</p>
)}
</div>
<div className="mt-4 flex items-center gap-3">
<button
type="button"
disabled={!canCreate}
onClick={() => onCreate({ title, description, days, books, image })}
onClick={() => onSubmit({ title, description, days, books, chapterRanges, 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
{isEditing ? 'Save changes' : 'Create plan'}
</button>
<button type="button" onClick={onCancel} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button>
</div>
</div>
);
}
// One book row in the custom-plan builder: a checkbox, and — once checked — a
// "ch. X–Y" chapter-range pair that defaults to the whole book.
function BookRow({ book, checked, range, onToggle, onRangeChange }) {
const total = CHAPTER_COUNTS[book.abbrev] ?? 1;
return (
<label className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-slate-50">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
className="h-4 w-4 shrink-0 rounded border-slate-300 text-rose-600 focus:ring-rose-500"
/>
<span className="flex-1">{book.name}</span>
{checked && total > 1 && (
<span className="flex shrink-0 items-center gap-1 text-xs text-slate-500" onClick={(e) => e.preventDefault()}>
ch.
<input
type="number"
min={1}
max={total}
value={range?.start ?? 1}
onChange={(e) => onRangeChange('start', e.target.value)}
className="w-11 rounded border border-slate-300 px-1 py-0.5 text-center"
/>
–
<input
type="number"
min={1}
max={total}
value={range?.end ?? total}
onChange={(e) => onRangeChange('end', e.target.value)}
className="w-11 rounded border border-slate-300 px-1 py-0.5 text-center"
/>
<span className="text-slate-400">/{total}</span>
</span>
)}
</label>
);
}