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:
1 parent
f1026dc194
commit
600b3d3ab9
21 files changed
+1166
-159
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user