Build and Push Docker Image / build-and-push (push) Failing after 19s
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>
317 lines
12 KiB
JavaScript
317 lines
12 KiB
JavaScript
// 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);
|
||
}
|