Files
study-bible-project/src/utils/readingPlans.js
T
nmemmertandClaude Sonnet 5.5 297f2c7e84
Build and Push Docker Image / build-and-push (push) Failing after 19s
Fix plan days with no chapters, add a way out of ink mode, harden ntfy reminders
Plans can no longer have more days than chapters (existing ones heal), and
opening a plan never strands the Reader on an empty day. The Reader shows
Done drawing while inking (Escape also exits). Reminders use the user's own
timezone, send within a 3 hour grace window, and Settings has a Send test
notification button.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:34:46 -04:00

317 lines
12 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;
}
// 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);
}