Add streaks, today view, custom plan editing/chapter ranges, and ntfy reminders
Build and Push Docker Image / build-and-push (push) Failing after 21s
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:
1 parent
600b3d3ab9
commit
1dd6aef6a5
10 files changed
+711
-141
No files matched your search
+75
-47
@@ -9,7 +9,7 @@ import {
|
||||
loadProjectIndex,
|
||||
} from '../utils/bibleUtils.jsx';
|
||||
import { loadProjectById } from '../utils/projectStorage.js';
|
||||
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex } from '../utils/readingPlans.js';
|
||||
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
|
||||
|
||||
export default function HomePage() {
|
||||
const {
|
||||
@@ -17,6 +17,8 @@ export default function HomePage() {
|
||||
staleLocalProjects,
|
||||
projectIndex,
|
||||
planProgress,
|
||||
readingActivity,
|
||||
toggleDay,
|
||||
openReadingPlans,
|
||||
readReadingPlanDay,
|
||||
audioBook, setAudioBook,
|
||||
@@ -153,6 +155,15 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Reading plans summary — shown regardless of whether there are any study projects yet */}
|
||||
<ReadingPlansSummaryCard
|
||||
planProgress={planProgress}
|
||||
readingActivity={readingActivity}
|
||||
openReadingPlans={openReadingPlans}
|
||||
readReadingPlanDay={readReadingPlanDay}
|
||||
toggleDay={toggleDay}
|
||||
/>
|
||||
|
||||
{projectIndex.length === 0 ? (
|
||||
<div className="mx-auto max-w-xl rounded-3xl border border-dashed border-slate-300 bg-white p-10 text-center shadow-panel">
|
||||
<p className="text-lg font-semibold text-slate-700">No projects yet</p>
|
||||
@@ -241,13 +252,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reading plans summary */}
|
||||
<ReadingPlansSummaryCard
|
||||
planProgress={planProgress}
|
||||
openReadingPlans={openReadingPlans}
|
||||
readReadingPlanDay={readReadingPlanDay}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<AudioPlayer
|
||||
title="Listen to BSB Audio"
|
||||
@@ -406,15 +410,25 @@ export default function HomePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ReadingPlansSummaryCard({ planProgress, openReadingPlans, readReadingPlanDay }) {
|
||||
function ReadingPlansSummaryCard({ planProgress, readingActivity, openReadingPlans, readReadingPlanDay, toggleDay }) {
|
||||
const active = planProgress.filter((p) => !p.completedAt);
|
||||
const streak = computeStreak(readingActivity);
|
||||
|
||||
const streakBadge = streak.current > 0 && (
|
||||
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">
|
||||
🔥 {streak.current} day{streak.current !== 1 ? 's' : ''}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (active.length === 0) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700">Reading Plans</p>
|
||||
<p className="text-xs text-slate-500">Read through the Bible, the Gospels, Psalms, and more — one day at a time.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-slate-700">Reading Plans</p>
|
||||
{streakBadge}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">Read through the Bible, the Gospels, Psalms, and more — one day at a time.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -427,52 +441,66 @@ function ReadingPlansSummaryCard({ planProgress, openReadingPlans, readReadingPl
|
||||
);
|
||||
}
|
||||
|
||||
const rows = active
|
||||
.map((progress) => {
|
||||
const plan = getPlan(progress.planId);
|
||||
if (!plan) return null;
|
||||
const schedule = getPlanSchedule(progress.planId);
|
||||
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
|
||||
const dayIndex = nextUnreadDayIndex(plan, progress.completedDays);
|
||||
const refs = dayIndex != null ? schedule[dayIndex] : null;
|
||||
return { progress, plan, pct, dayIndex, refs };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-3">
|
||||
{active.map((progress) => {
|
||||
const plan = getPlan(progress.planId);
|
||||
if (!plan) return null;
|
||||
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;
|
||||
return (
|
||||
<div key={progress.id} className="rounded-3xl border border-emerald-200 bg-emerald-50 p-5 shadow-panel">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600">Reading Plan</p>
|
||||
<h3 className="mt-1 text-base font-semibold text-slate-900">{plan.title}</h3>
|
||||
<p className="mt-0.5 text-sm text-slate-600">
|
||||
{progress.completedDays.length} / {plan.days} days
|
||||
{nextRefs && <> · Next: {formatDayReadings(nextRefs)}</>}
|
||||
<div className="mb-4 rounded-3xl border border-indigo-200 bg-indigo-50 p-5 shadow-panel">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-900">📅 Today's Reading</h3>
|
||||
{streakBadge}
|
||||
</div>
|
||||
<button type="button" onClick={openReadingPlans} className="text-xs font-semibold text-indigo-700 hover:underline">
|
||||
View all plans →
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{rows.map(({ progress, plan, pct, dayIndex, refs }) => (
|
||||
<div key={progress.id} className="rounded-2xl bg-white px-4 py-2.5 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{refs ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={false}
|
||||
onChange={() => toggleDay(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-emerald-500">✓</span>
|
||||
)}
|
||||
<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">
|
||||
{refs ? formatDayReadings(refs) : `${progress.completedDays.length} / ${plan.days} days`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{nextRefs && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => readReadingPlanDay(nextRefs[0].book, nextRefs[0].chapter)}
|
||||
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
)}
|
||||
{refs && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openReadingPlans}
|
||||
className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
|
||||
onClick={() => readReadingPlanDay(refs[0].book, refs[0].chapter)}
|
||||
className="shrink-0 rounded-xl bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500"
|
||||
>
|
||||
View
|
||||
Read
|
||||
</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 className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div className="h-full rounded-full bg-indigo-400 transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,23 @@ export default function ReaderPage() {
|
||||
.filter(Boolean);
|
||||
}, [planProgress, readerBookAbbrev, readerChapter]);
|
||||
|
||||
// Auto-marks matching plan days as read after a dwell period on the chapter,
|
||||
// so you don't have to remember to tap "Mark read" yourself. Resets whenever
|
||||
// you navigate to a different book/chapter; a manual unmark isn't re-marked.
|
||||
const planMatchesRef = useRef(planMatches);
|
||||
planMatchesRef.current = planMatches;
|
||||
const [autoMarkedNotice, setAutoMarkedNotice] = useState('');
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const justMarked = planMatchesRef.current.filter((m) => !m.done);
|
||||
if (justMarked.length === 0) return;
|
||||
justMarked.forEach((m) => toggleDay(m.progressId, m.dayIndex, m.plan.days));
|
||||
setAutoMarkedNotice(`✓ Marked read — ${justMarked.map((m) => m.plan.title).join(', ')}`);
|
||||
window.setTimeout(() => setAutoMarkedNotice(''), 4000);
|
||||
}, 20000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [readerBookAbbrev, readerChapter, toggleDay]);
|
||||
|
||||
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
||||
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
|
||||
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
|
||||
@@ -354,6 +371,12 @@ export default function ReaderPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{autoMarkedNotice && (
|
||||
<div className="mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-700">
|
||||
{autoMarkedNotice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool strip: font size · cross-refs · search */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-3xl border border-slate-200 bg-white px-4 py-3 shadow-panel">
|
||||
<span className="text-xs text-slate-500 mr-1">Text size</span>
|
||||
|
||||
+234
-52
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
disableMfa,
|
||||
changePassword,
|
||||
updateProfile,
|
||||
updateReminderSettings,
|
||||
} from '../syncService.js';
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -28,6 +29,15 @@ export default function SettingsPage() {
|
||||
const [changePasswordBusy, setChangePasswordBusy] = useState(false);
|
||||
const [changePasswordError, setChangePasswordError] = useState('');
|
||||
const [changePasswordSaved, setChangePasswordSaved] = useState(false);
|
||||
const [reminderForm, setReminderForm] = useState({
|
||||
reminderEnabled: authUser?.reminderEnabled ?? false,
|
||||
ntfyUrl: authUser?.ntfyUrl ?? '',
|
||||
ntfyTopic: authUser?.ntfyTopic ?? '',
|
||||
reminderTime: authUser?.reminderTime ?? '07:00',
|
||||
});
|
||||
const [reminderBusy, setReminderBusy] = useState(false);
|
||||
const [reminderError, setReminderError] = useState('');
|
||||
const [reminderSaved, setReminderSaved] = useState(false);
|
||||
|
||||
const startSetup = async () => {
|
||||
setMfaSetupError('');
|
||||
@@ -107,6 +117,21 @@ export default function SettingsPage() {
|
||||
window.setTimeout(() => setPodcastNameSaved(false), 2500);
|
||||
};
|
||||
|
||||
const submitReminderSettings = async (e) => {
|
||||
e.preventDefault();
|
||||
setReminderError('');
|
||||
setReminderBusy(true);
|
||||
const result = await updateReminderSettings(reminderForm);
|
||||
setReminderBusy(false);
|
||||
if (!result.ok) {
|
||||
setReminderError(result.error ?? 'Failed to save reminder settings.');
|
||||
return;
|
||||
}
|
||||
setAuthUser((u) => ({ ...u, ...result.data }));
|
||||
setReminderSaved(true);
|
||||
window.setTimeout(() => setReminderSaved(false), 2500);
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -217,6 +242,71 @@ export default function SettingsPage() {
|
||||
{podcastNameSaved && <p className="text-sm text-emerald-600">Saved.</p>}
|
||||
</section>
|
||||
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Reading reminders</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Get a daily nudge via{' '}
|
||||
<a href="https://ntfy.sh" target="_blank" rel="noreferrer" className="text-sky-600 underline">ntfy.sh</a>
|
||||
{' '}— a free push-notification service. Install the ntfy app (or open{' '}
|
||||
<span className="font-mono text-xs">{(reminderForm.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '')}/{reminderForm.ntfyTopic || '<topic>'}</span>
|
||||
{' '}in a browser) and subscribe to the topic below to receive it.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={submitReminderSettings} className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reminderForm.reminderEnabled}
|
||||
onChange={(e) => setReminderForm((f) => ({ ...f, reminderEnabled: e.target.checked }))}
|
||||
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
|
||||
/>
|
||||
Enable daily reading reminder
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block max-w-xs text-sm font-medium text-slate-700">
|
||||
Topic
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. nate-bible-reading-8f2"
|
||||
value={reminderForm.ntfyTopic}
|
||||
onChange={(e) => setReminderForm((f) => ({ ...f, ntfyTopic: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
<span className="mt-1 block text-xs text-slate-400">Pick something unique — anyone who knows it can subscribe.</span>
|
||||
</label>
|
||||
<label className="block max-w-xs text-sm font-medium text-slate-700">
|
||||
Reminder time
|
||||
<input
|
||||
type="time"
|
||||
value={reminderForm.reminderTime}
|
||||
onChange={(e) => setReminderForm((f) => ({ ...f, reminderTime: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block max-w-xs text-sm font-medium text-slate-700">
|
||||
ntfy server <span className="text-slate-400">(optional — defaults to ntfy.sh)</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://ntfy.sh"
|
||||
value={reminderForm.ntfyUrl}
|
||||
onChange={(e) => setReminderForm((f) => ({ ...f, ntfyUrl: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
{reminderError && <p className="text-sm text-rose-600">{reminderError}</p>}
|
||||
{reminderSaved && <p className="text-sm text-emerald-600">Saved.</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={reminderBusy}
|
||||
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{reminderBusy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Two-factor authentication</h2>
|
||||
|
||||
Reference in new issue
Block a user