Add reading plans: catalog, progress tracking, and custom plans

Replaces the old single-book reading goal with a full Reading Plans page:
9 built-in plans (Whole Bible, OT/NT, topical) with cover photos and
color-themed cards, day-by-day checklists synced to the server per user,
inline "mark today read" in the Bible reader, and user-created plans with
their own book picker and an optional uploaded cover photo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-23 12:51:28 -04:00
1 parent f1026dc194
commit 600b3d3ab9
21 files changed
+1166 -159

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
// Reading plan catalog — pure data + helpers. No React, no storage; progress
// tracking (and custom plan storage) lives in useReadingPlans.js.
import { bookOptions, CHAPTER_COUNTS, NT_BOOK_NUMBER } from './bibleUtils.jsx';
const BOOK_NAME_BY_ABBREV = Object.fromEntries(bookOptions.map((b) => [b.abbrev, b.name]));
export const ALL_BOOKS = bookOptions.map((b) => b.abbrev);
export const NT_BOOKS = ALL_BOOKS.filter((a) => NT_BOOK_NUMBER[a]);
export const OT_BOOKS = ALL_BOOKS.filter((a) => !NT_BOOK_NUMBER[a]);
export const GOSPELS = ['MAT', 'MRK', 'LUK', 'JHN'];
export const PENTATEUCH = ['GEN', 'EXO', 'LEV', 'NUM', 'DEU'];
export const WISDOM_BOOKS = ['JOB', 'PSA', 'PRO', 'ECC', 'SNG'];
// Flattens an ordered list of book abbreviations into one chapter-by-chapter list.
function chaptersFor(bookAbbrevs) {
const list = [];
for (const book of bookAbbrevs) {
const total = CHAPTER_COUNTS[book] ?? 0;
for (let chapter = 1; chapter <= total; chapter += 1) list.push({ book, chapter });
}
return list;
}
// Splits a flat chapter list into `days` nearly-equal, contiguous slices.
function distributeIntoDays(chapters, days) {
const total = chapters.length;
const schedule = [];
for (let d = 0; d < days; d += 1) {
const start = Math.round((d * total) / days);
const end = Math.round(((d + 1) * total) / days);
schedule.push(chapters.slice(start, end));
}
return schedule;
}
// Per-category color theme used for card accents/gradients when a plan has no cover image yet.
export const CATEGORY_THEME = {
'Whole Bible': { gradient: 'from-indigo-500 to-violet-600', text: 'text-indigo-600', chip: 'bg-indigo-100 text-indigo-700' },
'New Testament': { gradient: 'from-sky-500 to-blue-600', text: 'text-sky-600', chip: 'bg-sky-100 text-sky-700' },
'Old Testament': { gradient: 'from-amber-500 to-orange-600', text: 'text-amber-600', chip: 'bg-amber-100 text-amber-700' },
Topical: { gradient: 'from-emerald-500 to-teal-600', text: 'text-emerald-600', chip: 'bg-emerald-100 text-emerald-700' },
'My Plans': { gradient: 'from-rose-500 to-pink-600', text: 'text-rose-600', chip: 'bg-rose-100 text-rose-700' },
};
const DEFAULT_THEME = { gradient: 'from-slate-500 to-slate-700', text: 'text-slate-600', chip: 'bg-slate-100 text-slate-700' };
export function themeForCategory(category) {
return CATEGORY_THEME[category] ?? DEFAULT_THEME;
}
// Static catalog. `image` points at an optional cover photo the user can drop into
// public/plan-covers/ — see PIXABAY_SEARCH_LINKS below for where to find one. Cards
// fall back to a colorful gradient + icon when the file isn't there.
export const READING_PLANS = [
{
id: 'bible-year',
title: 'Whole Bible in a Year',
description: 'Read straight through Genesis to Revelation at a steady, sustainable pace.',
category: 'Whole Bible',
icon: '📖',
image: '/plan-covers/bible-year.jpg',
days: 365,
books: ALL_BOOKS,
},
{
id: 'bible-90',
title: 'Whole Bible in 90 Days',
description: 'An intensive read of the entire Bible, cover to cover.',
category: 'Whole Bible',
icon: '⏳',
image: '/plan-covers/bible-90.jpg',
days: 90,
books: ALL_BOOKS,
},
{
id: 'nt-90',
title: 'New Testament in 90 Days',
description: 'Matthew through Revelation in three months.',
category: 'New Testament',
icon: '✝️',
image: '/plan-covers/nt-90.jpg',
days: 90,
books: NT_BOOKS,
},
{
id: 'nt-30',
title: 'New Testament in 30 Days',
description: 'A fast-paced trip through the whole New Testament.',
category: 'New Testament',
icon: '⚡',
image: '/plan-covers/nt-30.jpg',
imageFocus: '50% 35%',
days: 30,
books: NT_BOOKS,
},
{
id: 'gospels-30',
title: 'The Gospels in 30 Days',
description: 'Matthew, Mark, Luke, and John — the life of Jesus in a month.',
category: 'Topical',
icon: '🕊️',
image: '/plan-covers/gospels-30.jpg',
days: 30,
books: GOSPELS,
},
{
id: 'psalms-30',
title: 'Psalms in 30 Days',
description: 'Pray and read through all 150 Psalms in a month.',
category: 'Topical',
icon: '🎵',
image: '/plan-covers/psalms-30.jpg',
days: 30,
books: ['PSA'],
},
{
id: 'proverbs-31',
title: 'Proverbs in 31 Days',
description: 'One chapter a day — a natural fit for any month.',
category: 'Topical',
icon: '💡',
image: '/plan-covers/proverbs-31.jpg',
days: 31,
books: ['PRO'],
},
{
id: 'pentateuch-60',
title: 'The Pentateuch in 60 Days',
description: 'Genesis through Deuteronomy — the foundation of the Old Testament.',
category: 'Old Testament',
icon: '🏜️',
image: '/plan-covers/pentateuch-60.jpg',
days: 60,
books: PENTATEUCH,
},
{
id: 'wisdom-40',
title: 'Wisdom Literature in 40 Days',
description: 'Job, Psalms, Proverbs, Ecclesiastes, and Song of Songs.',
category: 'Topical',
icon: '🦉',
image: '/plan-covers/wisdom-40.jpg',
imageFocus: '50% 32%',
days: 40,
books: WISDOM_BOOKS,
},
];
// Pixabay *search* pages (not specific photos — nothing here is a guessed asset URL)
// for each built-in plan, so a cover photo can be picked and dropped into
// public/plan-covers/<id>.jpg. Landscape, ~1200px wide, works best.
export const PIXABAY_SEARCH_LINKS = {
'bible-year': 'https://pixabay.com/images/search/open%20bible/',
'bible-90': 'https://pixabay.com/images/search/bible%20pages/',
'nt-90': 'https://pixabay.com/images/search/cross%20sunrise/',
'nt-30': 'https://pixabay.com/images/search/cross/',
'gospels-30': 'https://pixabay.com/images/search/jesus/',
'psalms-30': 'https://pixabay.com/images/search/harp/',
'proverbs-31': 'https://pixabay.com/images/search/candle%20light/',
'pentateuch-60': 'https://pixabay.com/images/search/desert%20mountain/',
'wisdom-40': 'https://pixabay.com/images/search/owl%20book/',
};
const PLAN_BY_ID = Object.fromEntries(READING_PLANS.map((p) => [p.id, p]));
const scheduleCache = new Map();
export function getPlan(planId) {
return PLAN_BY_ID[planId] ?? null;
}
// Registers/updates/removes user-created plans in the lookup used by getPlan/getPlanSchedule.
// Called by useReadingPlans() whenever the custom-plans list changes.
export function registerCustomPlans(customPlans) {
for (const key of Object.keys(PLAN_BY_ID)) {
if (PLAN_BY_ID[key].custom && !customPlans.some((cp) => cp.id === key)) {
delete PLAN_BY_ID[key];
scheduleCache.delete(key);
}
}
for (const cp of customPlans) {
PLAN_BY_ID[cp.id] = { category: 'My Plans', icon: '✨', ...cp, custom: true };
scheduleCache.delete(cp.id);
}
}
// Returns an array of `plan.days` entries, each an array of { book, chapter } refs.
export function getPlanSchedule(planId) {
if (scheduleCache.has(planId)) return scheduleCache.get(planId);
const plan = getPlan(planId);
if (!plan) return [];
const schedule = distributeIntoDays(chaptersFor(plan.books), plan.days);
scheduleCache.set(planId, schedule);
return schedule;
}
// Groups consecutive same-book chapters and renders "Genesis 1–3; Exodus 1" style text.
export function formatDayReadings(dayRefs) {
if (!dayRefs || dayRefs.length === 0) return '';
const groups = [];
for (const ref of dayRefs) {
const last = groups[groups.length - 1];
if (last && last.book === ref.book && ref.chapter === last.end + 1) {
last.end = ref.chapter;
} else {
groups.push({ book: ref.book, start: ref.chapter, end: ref.chapter });
}
}
return groups
.map((g) => {
const name = BOOK_NAME_BY_ABBREV[g.book] ?? g.book;
return g.start === g.end ? `${name} ${g.start}` : `${name} ${g.start}–${g.end}`;
})
.join('; ');
}
// The first chapter reference of a day's reading — where "Read" should jump to.
export function firstRefOfDay(dayRefs) {
return dayRefs && dayRefs.length > 0 ? dayRefs[0] : null;
}
// Index (0-based) of the first day not yet marked complete, or null if the plan is finished.
export function nextUnreadDayIndex(plan, completedDays) {
for (let i = 0; i < plan.days; i += 1) {
if (!completedDays.includes(i)) return i;
}
return null;
}
// Index (0-based) of the day whose reading includes this exact book/chapter, or -1.
// Used by the reader to offer "mark today's plan reading done" inline.
export function findDayIndexForRef(schedule, bookAbbrev, chapter) {
for (let i = 0; i < schedule.length; i += 1) {
if (schedule[i].some((r) => r.book === bookAbbrev && r.chapter === chapter)) return i;
}
return -1;
}