Files
study-bible-project/src/utils/readingPlans.js
T
nmemmertandClaude Sonnet 5 1dd6aef6a5
Build and Push Docker Image / build-and-push (push) Failing after 21s
Add streaks, today view, custom plan editing/chapter ranges, and ntfy reminders
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>
2026-09-23 13:16:27 -04:00

297 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/<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.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);
}