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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user