// 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. // `chapterRanges` (optional) is { [bookAbbrev]: { start, end } } — a custom plan's // per-book override to read only part of a book instead of the whole thing. function chaptersFor(bookAbbrevs, chapterRanges) { const list = []; for (const book of bookAbbrevs) { const total = CHAPTER_COUNTS[book] ?? 0; const range = chapterRanges?.[book]; const start = Math.max(1, range?.start ?? 1); const end = Math.min(total, range?.end ?? total); for (let chapter = start; chapter <= end; chapter += 1) list.push({ book, chapter }); } return list; } // Chapter count a book contributes to a plan, honoring a chapter-range override. export function bookChapterCount(bookAbbrev, chapterRanges) { const total = CHAPTER_COUNTS[bookAbbrev] ?? 0; const range = chapterRanges?.[bookAbbrev]; if (!range) return total; return Math.max(0, Math.min(total, range.end) - Math.max(1, range.start) + 1); } // 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/.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; } // A plan can't have more days than it has chapters (the extra days would be empty, with // nothing to read). Existing custom plans with too many days heal to one chapter a day. export function clampPlanDays(books, chapterRanges, days) { const total = (books ?? []).reduce((sum, b) => sum + bookChapterCount(b, chapterRanges), 0); const wanted = Math.max(1, Math.round(Number(days) || 1)); return total > 0 ? Math.min(wanted, total) : wanted; } export function normalizeCustomPlan(plan) { const days = clampPlanDays(plan.books, plan.chapterRanges, plan.days); return days === plan.days ? plan : { ...plan, days }; } // Nearest day at or after `from` that has something to read, else the nearest one before it. export function nearestReadingDay(schedule, from) { for (let i = Math.max(0, from); i < schedule.length; i += 1) if (schedule[i]?.length) return i; for (let i = Math.min(from, schedule.length) - 1; i >= 0; i -= 1) if (schedule[i]?.length) return i; return -1; } // 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: '✨', ...normalizeCustomPlan(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.chapterRanges), 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; } // --------------------------------------------------------------------------- // Streaks & pace — driven by a plain list of 'YYYY-MM-DD' local-date strings // (one per calendar day something was marked read, across all plans). // --------------------------------------------------------------------------- export function localDateString(ts = Date.now()) { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } // { current, longest } consecutive-calendar-day streak. "current" stays alive // through today even if today hasn't been logged yet (so it doesn't drop to 0 // the moment you wake up) but breaks once a full day is skipped. export function computeStreak(activityDates) { if (!activityDates || activityDates.length === 0) return { current: 0, longest: 0 }; const daySet = new Set(activityDates); const DAY_MS = 86400000; const sorted = Array.from(daySet).sort(); let longest = 0; let run = 0; let prevTime = null; for (const ds of sorted) { const t = new Date(`${ds}T00:00:00`).getTime(); run = prevTime != null && t - prevTime === DAY_MS ? run + 1 : 1; longest = Math.max(longest, run); prevTime = t; } let current = 0; const cursor = new Date(); cursor.setHours(0, 0, 0, 0); if (!daySet.has(localDateString(cursor.getTime()))) cursor.setDate(cursor.getDate() - 1); while (daySet.has(localDateString(cursor.getTime()))) { current += 1; cursor.setDate(cursor.getDate() - 1); } return { current, longest }; } // How many days a plan's pace has fallen behind, assuming one plan-day per // calendar day since it was started. Purely informational — nothing enforces it. export function daysBehindPace(plan, progress) { const elapsedDays = Math.floor((Date.now() - progress.startedAt) / 86400000); const expectedDayIndex = Math.min(plan.days - 1, Math.max(0, elapsedDays)); return Math.max(0, expectedDayIndex - progress.completedDays.length); }