Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dd6aef6a5 | ||
|
|
600b3d3ab9 |
No files matched your search
@@ -0,0 +1,18 @@
|
|||||||
|
Drop a cover photo here for each built-in reading plan, named exactly:
|
||||||
|
|
||||||
|
bible-year.jpg Whole Bible in a Year
|
||||||
|
bible-90.jpg Whole Bible in 90 Days
|
||||||
|
nt-90.jpg New Testament in 90 Days
|
||||||
|
nt-30.jpg New Testament in 30 Days
|
||||||
|
gospels-30.jpg The Gospels in 30 Days
|
||||||
|
psalms-30.jpg Psalms in 30 Days
|
||||||
|
proverbs-31.jpg Proverbs in 31 Days
|
||||||
|
pentateuch-60.jpg The Pentateuch in 60 Days
|
||||||
|
wisdom-40.jpg Wisdom Literature in 40 Days
|
||||||
|
|
||||||
|
Landscape orientation, ~1200px wide, works best (cards crop to a 16:9 box).
|
||||||
|
Any plan without a matching file just shows its colorful gradient + icon —
|
||||||
|
nothing breaks if some are missing.
|
||||||
|
|
||||||
|
See src/utils/readingPlans.js -> PIXABAY_SEARCH_LINKS for a Pixabay search
|
||||||
|
link per plan to help pick one.
|
||||||
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 202 KiB |
@@ -63,6 +63,17 @@ export function initDb() {
|
|||||||
db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT');
|
db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Older databases predate ntfy.sh daily reading reminders.
|
||||||
|
if (!userCols.some((c) => c.name === 'ntfy_url')) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE users ADD COLUMN ntfy_url TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN ntfy_topic TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN reminder_time TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN reminder_enabled INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE users ADD COLUMN last_reminder_date TEXT;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
// Older databases predate shareable read-only links.
|
// Older databases predate shareable read-only links.
|
||||||
if (!projectCols.some((c) => c.name === 'share_token')) {
|
if (!projectCols.some((c) => c.name === 'share_token')) {
|
||||||
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
|
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
|
||||||
@@ -81,6 +92,16 @@ export function initDb() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Reading plan progress — one row per user, holding the whole list of
|
||||||
|
// in-progress/completed plans as JSON (small; no need to normalize).
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS reading_progress (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
console.log(`SQLite database ready at ${DB_PATH}`);
|
console.log(`SQLite database ready at ${DB_PATH}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +136,7 @@ function parseUserRow(row) {
|
|||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
totpEnabled: !!row.totpEnabled,
|
totpEnabled: !!row.totpEnabled,
|
||||||
|
reminderEnabled: !!row.reminderEnabled,
|
||||||
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -122,7 +144,9 @@ function parseUserRow(row) {
|
|||||||
const USER_SELECT = `
|
const USER_SELECT = `
|
||||||
SELECT id, email, password_hash AS passwordHash, created_at AS createdAt,
|
SELECT id, email, password_hash AS passwordHash, created_at AS createdAt,
|
||||||
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
|
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
|
||||||
podcast_name AS podcastName
|
podcast_name AS podcastName,
|
||||||
|
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
|
||||||
|
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate
|
||||||
FROM users
|
FROM users
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -153,6 +177,33 @@ export function setPodcastName(userId, podcastName) {
|
|||||||
db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId);
|
db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Daily reading reminders (sent via ntfy.sh — see server/reminders.js)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Saves a user's ntfy reminder settings ({ ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }). */
|
||||||
|
export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }) {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE users SET ntfy_url = ?, ntfy_topic = ?, reminder_time = ?, reminder_enabled = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every account with reminders turned on and a topic configured — checked once a minute. */
|
||||||
|
export function getUsersWithRemindersEnabled() {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
|
||||||
|
last_reminder_date AS lastReminderDate
|
||||||
|
FROM users
|
||||||
|
WHERE reminder_enabled = 1 AND ntfy_topic IS NOT NULL AND reminder_time IS NOT NULL
|
||||||
|
`).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks that a user's reminder was sent today, so it isn't sent twice. */
|
||||||
|
export function setLastReminderDate(userId, dateString) {
|
||||||
|
db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
|
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
|
||||||
export function setBackupCodeHashes(userId, backupCodeHashes) {
|
export function setBackupCodeHashes(userId, backupCodeHashes) {
|
||||||
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
|
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
|
||||||
@@ -182,6 +233,30 @@ export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
|
|||||||
`).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now());
|
`).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reading plan progress — cross-device sync for started/completed reading plans
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Returns the user's reading-plan data ({ progress, customPlans }, parsed JSON), or an empty shell. */
|
||||||
|
export function getReadingProgress(userId) {
|
||||||
|
const row = db.prepare('SELECT data FROM reading_progress WHERE user_id = ?').get(userId);
|
||||||
|
if (!row) return { progress: [], customPlans: [] };
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.data);
|
||||||
|
} catch {
|
||||||
|
return { progress: [], customPlans: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overwrites the user's whole reading-plan progress list. */
|
||||||
|
export function setReadingProgress(userId, progress) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO reading_progress (user_id, data, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||||
|
`).run(userId, JSON.stringify(progress), Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assigns any pre-existing, unowned projects (from before multi-user support)
|
* Assigns any pre-existing, unowned projects (from before multi-user support)
|
||||||
* to the given user. Intended to run once, right after the first account is created.
|
* to the given user. Intended to run once, right after the first account is created.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||||
setUserPassword, destroyAllSessionsForUser,
|
setUserPassword, destroyAllSessionsForUser,
|
||||||
getAllReaderInk, setReaderInkPage,
|
getAllReaderInk, setReaderInkPage,
|
||||||
|
getReadingProgress, setReadingProgress,
|
||||||
|
setReminderSettings,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { SqliteSessionStore } from './sessionStore.js';
|
import { SqliteSessionStore } from './sessionStore.js';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +22,17 @@ import {
|
|||||||
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
||||||
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
|
import { startReminderScheduler } from './reminders.js';
|
||||||
|
|
||||||
|
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
|
||||||
|
function userPayload(user) {
|
||||||
|
return {
|
||||||
|
id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null,
|
||||||
|
isAdmin: isAdminEmail(user.email),
|
||||||
|
reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null,
|
||||||
|
reminderTime: user.reminderTime ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -107,7 +120,10 @@ app.post('/api/auth/register', async (req, res) => {
|
|||||||
req.session.regenerate((err) => {
|
req.session.regenerate((err) => {
|
||||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email) });
|
res.json({
|
||||||
|
id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email),
|
||||||
|
reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('POST /api/auth/register error:', err);
|
console.error('POST /api/auth/register error:', err);
|
||||||
@@ -136,7 +152,7 @@ app.post('/api/auth/login', async (req, res) => {
|
|||||||
return res.json({ mfaRequired: true });
|
return res.json({ mfaRequired: true });
|
||||||
}
|
}
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
res.json(userPayload(user));
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('POST /api/auth/login error:', err);
|
console.error('POST /api/auth/login error:', err);
|
||||||
@@ -175,7 +191,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
|
|||||||
req.session.regenerate((err) => {
|
req.session.regenerate((err) => {
|
||||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
res.json({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
res.json(userPayload(user));
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('POST /api/auth/mfa/verify error:', err);
|
console.error('POST /api/auth/mfa/verify error:', err);
|
||||||
@@ -193,10 +209,7 @@ app.post('/api/auth/logout', (req, res) => {
|
|||||||
app.get('/api/auth/me', (req, res) => {
|
app.get('/api/auth/me', (req, res) => {
|
||||||
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||||
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
||||||
res.json({
|
res.json(userPayload(user));
|
||||||
id: user.id, email: user.email, totpEnabled: user.totpEnabled,
|
|
||||||
podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -213,6 +226,44 @@ app.patch('/api/auth/profile', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const reminderEnabled = !!req.body?.reminderEnabled;
|
||||||
|
const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
|
||||||
|
const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim();
|
||||||
|
const reminderTime = String(req.body?.reminderTime ?? '').trim();
|
||||||
|
|
||||||
|
if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||||||
|
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||||||
|
}
|
||||||
|
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||||||
|
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||||||
|
}
|
||||||
|
if (reminderEnabled) {
|
||||||
|
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' });
|
||||||
|
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) {
|
||||||
|
return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setReminderSettings(req.session.userId, {
|
||||||
|
ntfyUrl: ntfyUrl || null,
|
||||||
|
ntfyTopic: ntfyTopic || null,
|
||||||
|
reminderTime: reminderTime || null,
|
||||||
|
reminderEnabled,
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PATCH /api/auth/reminder-settings error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save reminder settings.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /api/auth/change-password — self-service password change (Account Settings)
|
// POST /api/auth/change-password — self-service password change (Account Settings)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -394,6 +445,35 @@ app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reading plan progress — cross-device sync for started/completed reading plans
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GET /api/reading-plans — the current user's whole progress list
|
||||||
|
app.get('/api/reading-plans', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(getReadingProgress(req.session.userId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/reading-plans error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load reading plan progress.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/reading-plans — overwrite the current user's whole progress list
|
||||||
|
// (an object: { progress: [...], customPlans: [...] })
|
||||||
|
app.put('/api/reading-plans', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||||
|
return res.status(400).json({ error: 'Body must be an object.' });
|
||||||
|
}
|
||||||
|
setReadingProgress(req.session.userId, req.body);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PUT /api/reading-plans error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save reading plan progress.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Read-only share links
|
// Read-only share links
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -545,6 +625,7 @@ if (isProd) {
|
|||||||
// Start
|
// Start
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
initDb();
|
initDb();
|
||||||
|
startReminderScheduler();
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Bible Study API running on http://localhost:${PORT}`);
|
console.log(`Bible Study API running on http://localhost:${PORT}`);
|
||||||
if (isProd) {
|
if (isProd) {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Daily "don't forget to read" reminders, sent via ntfy.sh (https://ntfy.sh) —
|
||||||
|
// a free pub/sub push service: POST a message to a topic URL, anyone
|
||||||
|
// subscribed to that topic (via the ntfy app or a browser) gets notified.
|
||||||
|
// No API keys or push-subscription plumbing needed.
|
||||||
|
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js';
|
||||||
|
|
||||||
|
function todayDateString(d = new Date()) {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentHHMM(d = new Date()) {
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNtfyReminder(user) {
|
||||||
|
const base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||||
|
const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Title: 'Reading Plan Reminder', Tags: 'open_book' },
|
||||||
|
body: "Don't forget today's reading!",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`ntfy responded ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkReminders() {
|
||||||
|
const now = new Date();
|
||||||
|
const hhmm = currentHHMM(now);
|
||||||
|
const today = todayDateString(now);
|
||||||
|
const due = getUsersWithRemindersEnabled().filter(
|
||||||
|
(u) => u.reminderTime === hhmm && u.lastReminderDate !== today,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const user of due) {
|
||||||
|
try {
|
||||||
|
const progress = getReadingProgress(user.id);
|
||||||
|
const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt);
|
||||||
|
if (!hasActivePlan) continue; // nothing to nudge them about
|
||||||
|
await sendNtfyReminder(user);
|
||||||
|
setLastReminderDate(user.id, today);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Reading reminder failed for user ${user.id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks once a minute whether any user's reminder time has arrived. */
|
||||||
|
export function startReminderScheduler() {
|
||||||
|
setInterval(() => {
|
||||||
|
checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@ import { useAudio } from './hooks/useAudio.js';
|
|||||||
import { useAuth } from './hooks/useAuth.js';
|
import { useAuth } from './hooks/useAuth.js';
|
||||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||||
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
||||||
import { useReadingPlan } from './hooks/useReadingPlan.js';
|
import { useReadingPlans } from './hooks/useReadingPlans.js';
|
||||||
import { useSharedView } from './hooks/useSharedView.js';
|
import { useSharedView } from './hooks/useSharedView.js';
|
||||||
import HomePage from './pages/HomePage.jsx';
|
import HomePage from './pages/HomePage.jsx';
|
||||||
import { saveRemoteProject } from './syncService.js';
|
import { getRemoteReadingProgress, saveRemoteProject } from './syncService.js';
|
||||||
import { buildExportHtml, loadProjectIndex, switchStorageUser } from './utils/bibleUtils.jsx';
|
import { buildExportHtml, loadProjectIndex, switchStorageUser } from './utils/bibleUtils.jsx';
|
||||||
import { migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js';
|
import { migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js';
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
|
|||||||
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
|
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
|
||||||
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
|
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
|
||||||
const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
|
const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
|
||||||
|
const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx'));
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App component
|
// App component
|
||||||
@@ -28,7 +29,7 @@ const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
|
|||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [project, setProject] = useState(null);
|
const [project, setProject] = useState(null);
|
||||||
// 'home' | 'setup' | 'study' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin'
|
// 'home' | 'setup' | 'study' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans'
|
||||||
const [currentPage, setCurrentPage] = useState('home');
|
const [currentPage, setCurrentPage] = useState('home');
|
||||||
const [projectIndex, setProjectIndex] = useState([]);
|
const [projectIndex, setProjectIndex] = useState([]);
|
||||||
const [saveStatus, setSaveStatus] = useState('');
|
const [saveStatus, setSaveStatus] = useState('');
|
||||||
@@ -36,7 +37,7 @@ const App = () => {
|
|||||||
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
||||||
const reader = useBibleReader(currentPage);
|
const reader = useBibleReader(currentPage);
|
||||||
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
||||||
const readingPlan = useReadingPlan();
|
const readingPlans = useReadingPlans();
|
||||||
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
|
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
|
||||||
onSignIn: (user) => {
|
onSignIn: (user) => {
|
||||||
setProjectIndex(switchStorageUser(user.id));
|
setProjectIndex(switchStorageUser(user.id));
|
||||||
@@ -45,6 +46,10 @@ const App = () => {
|
|||||||
.then((r) => r.ok ? r.json() : null)
|
.then((r) => r.ok ? r.json() : null)
|
||||||
.then((serverInk) => { if (serverInk) reader.mergeServerInk(serverInk); })
|
.then((serverInk) => { if (serverInk) reader.mergeServerInk(serverInk); })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
// Same for reading plan progress
|
||||||
|
getRemoteReadingProgress().then((result) => {
|
||||||
|
if (result.ok) readingPlans.mergeServerReadingProgress(result.data);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSignOut: () => {
|
onSignOut: () => {
|
||||||
setProjectIndex(switchStorageUser(null));
|
setProjectIndex(switchStorageUser(null));
|
||||||
@@ -110,6 +115,14 @@ const App = () => {
|
|||||||
// Navigation
|
// Navigation
|
||||||
const openBibleReader = () => setCurrentPage('reader');
|
const openBibleReader = () => setCurrentPage('reader');
|
||||||
|
|
||||||
|
const openReadingPlans = () => setCurrentPage('reading-plans');
|
||||||
|
|
||||||
|
// Jumps straight into a specific plan day's first chapter and opens the reader.
|
||||||
|
const readReadingPlanDay = (bookAbbrev, chapter) => {
|
||||||
|
reader.jumpToReaderVerse(bookAbbrev, chapter, 1);
|
||||||
|
setCurrentPage('reader');
|
||||||
|
};
|
||||||
|
|
||||||
const openNewProject = () => {
|
const openNewProject = () => {
|
||||||
setProject(null);
|
setProject(null);
|
||||||
setCurrentPage('setup');
|
setCurrentPage('setup');
|
||||||
@@ -155,8 +168,10 @@ const App = () => {
|
|||||||
// Home
|
// Home
|
||||||
projectIndex,
|
projectIndex,
|
||||||
...library,
|
...library,
|
||||||
...readingPlan,
|
...readingPlans,
|
||||||
openBibleReader,
|
openBibleReader,
|
||||||
|
openReadingPlans,
|
||||||
|
readReadingPlanDay,
|
||||||
openImportProject,
|
openImportProject,
|
||||||
openNewProject,
|
openNewProject,
|
||||||
// Audio (book player on Home + chapter player in the Reader)
|
// Audio (book player on Home + chapter player in the Reader)
|
||||||
@@ -238,6 +253,7 @@ function AppRouter() {
|
|||||||
|
|
||||||
if (currentPage === 'admin') return <AdminPage />;
|
if (currentPage === 'admin') return <AdminPage />;
|
||||||
if (currentPage === 'settings') return <SettingsPage />;
|
if (currentPage === 'settings') return <SettingsPage />;
|
||||||
|
if (currentPage === 'reading-plans') return <ReadingPlansPage />;
|
||||||
if (currentPage === 'reader') return <ReaderPage />;
|
if (currentPage === 'reader') return <ReaderPage />;
|
||||||
if (currentPage === 'import') return <ImportPage />;
|
if (currentPage === 'import') return <ImportPage />;
|
||||||
if (currentPage === 'study') return <StudyPage />;
|
if (currentPage === 'study') return <StudyPage />;
|
||||||
|
|||||||
@@ -580,13 +580,16 @@ describe('Home page', () => {
|
|||||||
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
|
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a reading plan can be started from Home', async () => {
|
test('a reading plan can be started from the Reading Plans page', async () => {
|
||||||
seedProject(); // the plan card shows once there's at least one project
|
seedProject(); // the nav button shows once there's at least one project
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await renderApp();
|
await renderApp();
|
||||||
await user.click(screen.getByRole('button', { name: /set goal/i }));
|
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||||
await user.click(screen.getByRole('button', { name: /start plan/i }));
|
await user.click((await screen.findAllByRole('button', { name: /^start plan$/i }))[0]);
|
||||||
await waitFor(() => expect(JSON.parse(localStorage.getItem('readingPlan'))?.bookAbbrev).toBe('GEN'));
|
await waitFor(() => {
|
||||||
|
const stored = JSON.parse(localStorage.getItem('readingPlanProgress'));
|
||||||
|
expect(stored?.progress?.[0]?.planId).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
// A simple "read this book in N weeks" plan, stored per browser.
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
|
|
||||||
|
|
||||||
export function useReadingPlan() {
|
|
||||||
const [readingPlan, setReadingPlan] = useState(() => {
|
|
||||||
try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; }
|
|
||||||
});
|
|
||||||
|
|
||||||
const createReadingPlan = (bookAbbrev, bookName, weeks) => {
|
|
||||||
const totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? 1;
|
|
||||||
const plan = { bookAbbrev, bookName, totalChapters, startDate: Date.now(), targetDate: Date.now() + weeks * 7 * 24 * 60 * 60 * 1000, chaptersRead: [] };
|
|
||||||
setReadingPlan(plan);
|
|
||||||
try { localStorage.setItem('readingPlan', JSON.stringify(plan)); } catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const markChapterRead = (chapter) => {
|
|
||||||
setReadingPlan((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
const chaptersRead = prev.chaptersRead.includes(chapter)
|
|
||||||
? prev.chaptersRead.filter((c) => c !== chapter)
|
|
||||||
: [...prev.chaptersRead, chapter].sort((a, b) => a - b);
|
|
||||||
const next = { ...prev, chaptersRead };
|
|
||||||
try { localStorage.setItem('readingPlan', JSON.stringify(next)); } catch {}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearReadingPlan = () => {
|
|
||||||
setReadingPlan(null);
|
|
||||||
try { localStorage.removeItem('readingPlan'); } catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { readingPlan, createReadingPlan, markChapterRead, clearReadingPlan };
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Tracks which reading plans (see utils/readingPlans.js) a user has started,
|
||||||
|
// which days they've checked off, which plans they've finished, any plans
|
||||||
|
// they've created themselves, and a cross-plan activity log (for streaks).
|
||||||
|
// Saved locally right away and synced to the server in the background — same
|
||||||
|
// "local wins immediately, server wins at sign-in" pattern as reader ink.
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { saveRemoteReadingProgress } from '../syncService.js';
|
||||||
|
import { localDateString, registerCustomPlans } from '../utils/readingPlans.js';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'readingPlanProgress';
|
||||||
|
|
||||||
|
function loadLocal() {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
|
||||||
|
if (Array.isArray(raw)) return { progress: raw, customPlans: [], activity: [] }; // pre-custom-plans shape
|
||||||
|
return {
|
||||||
|
progress: raw?.progress ?? [],
|
||||||
|
customPlans: raw?.customPlans ?? [],
|
||||||
|
activity: raw?.activity ?? [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { progress: [], customPlans: [], activity: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(next) {
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
|
||||||
|
saveRemoteReadingProgress(next); // fire-and-forget; errors are silent, local copy is already safe
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeId = () => (crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`);
|
||||||
|
|
||||||
|
export function useReadingPlans() {
|
||||||
|
const [state, setState] = useState(loadLocal);
|
||||||
|
// Keeps the readingPlans.js lookup (getPlan/getPlanSchedule) in sync with
|
||||||
|
// this user's custom plans before any consumer renders this commit.
|
||||||
|
registerCustomPlans(state.customPlans);
|
||||||
|
|
||||||
|
const updateState = useCallback((updater) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const next = updater(prev);
|
||||||
|
persist(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateProgress = useCallback((updater) => {
|
||||||
|
updateState((prev) => ({ ...prev, progress: updater(prev.progress) }));
|
||||||
|
}, [updateState]);
|
||||||
|
|
||||||
|
const updateCustomPlans = useCallback((updater) => {
|
||||||
|
updateState((prev) => ({ ...prev, customPlans: updater(prev.customPlans) }));
|
||||||
|
}, [updateState]);
|
||||||
|
|
||||||
|
const startPlan = useCallback((planId) => {
|
||||||
|
updateProgress((prev) => {
|
||||||
|
if (prev.some((p) => p.planId === planId && !p.completedAt)) return prev;
|
||||||
|
const record = {
|
||||||
|
id: makeId(),
|
||||||
|
planId,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
completedDays: [],
|
||||||
|
completedAt: null,
|
||||||
|
lastActivityAt: Date.now(),
|
||||||
|
};
|
||||||
|
return [record, ...prev];
|
||||||
|
});
|
||||||
|
}, [updateProgress]);
|
||||||
|
|
||||||
|
const toggleDay = useCallback((progressId, dayIndex, totalDays) => {
|
||||||
|
updateState((prev) => {
|
||||||
|
const target = prev.progress.find((p) => p.id === progressId);
|
||||||
|
if (!target) return prev;
|
||||||
|
const wasAlready = target.completedDays.includes(dayIndex);
|
||||||
|
const progress = prev.progress.map((p) => {
|
||||||
|
if (p.id !== progressId) return p;
|
||||||
|
const completedDays = wasAlready
|
||||||
|
? p.completedDays.filter((d) => d !== dayIndex)
|
||||||
|
: [...p.completedDays, dayIndex].sort((a, b) => a - b);
|
||||||
|
const completedAt = completedDays.length >= totalDays ? (p.completedAt ?? Date.now()) : null;
|
||||||
|
return { ...p, completedDays, completedAt, lastActivityAt: Date.now() };
|
||||||
|
});
|
||||||
|
// Only marking a day *done* extends the streak; unchecking one doesn't erase it.
|
||||||
|
const today = localDateString();
|
||||||
|
const activity = !wasAlready && !prev.activity.includes(today)
|
||||||
|
? [...prev.activity, today]
|
||||||
|
: prev.activity;
|
||||||
|
return { ...prev, progress, activity };
|
||||||
|
});
|
||||||
|
}, [updateState]);
|
||||||
|
|
||||||
|
const restartPlan = useCallback((progressId) => {
|
||||||
|
updateProgress((prev) => prev.map((p) => (
|
||||||
|
p.id === progressId
|
||||||
|
? { ...p, completedDays: [], completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
|
||||||
|
: p
|
||||||
|
)));
|
||||||
|
}, [updateProgress]);
|
||||||
|
|
||||||
|
const removePlanProgress = useCallback((progressId) => {
|
||||||
|
updateProgress((prev) => prev.filter((p) => p.id !== progressId));
|
||||||
|
}, [updateProgress]);
|
||||||
|
|
||||||
|
// Resets the pace clock so a plan that's fallen behind schedule stops being
|
||||||
|
// flagged as "behind" — doesn't touch which days are actually marked done.
|
||||||
|
const recalibratePace = useCallback((progressId) => {
|
||||||
|
updateProgress((prev) => prev.map((p) => (
|
||||||
|
p.id === progressId
|
||||||
|
? { ...p, startedAt: Date.now() - p.completedDays.length * 86400000, lastActivityAt: Date.now() }
|
||||||
|
: p
|
||||||
|
)));
|
||||||
|
}, [updateProgress]);
|
||||||
|
|
||||||
|
// { title, description, days, books: [abbrev, ...], chapterRanges?, image?: dataUrl } -> new custom plan id
|
||||||
|
const addCustomPlan = useCallback(({ title, description, days, books, chapterRanges, image }) => {
|
||||||
|
const id = `custom-${makeId()}`;
|
||||||
|
updateCustomPlans((prev) => [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
title: title.trim(),
|
||||||
|
description: (description ?? '').trim(),
|
||||||
|
category: 'My Plans',
|
||||||
|
icon: '✨',
|
||||||
|
image: image || undefined,
|
||||||
|
days: Math.max(1, Math.round(days)),
|
||||||
|
books,
|
||||||
|
chapterRanges: chapterRanges && Object.keys(chapterRanges).length > 0 ? chapterRanges : undefined,
|
||||||
|
custom: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
]);
|
||||||
|
return id;
|
||||||
|
}, [updateCustomPlans]);
|
||||||
|
|
||||||
|
const removeCustomPlan = useCallback((planId) => {
|
||||||
|
updateCustomPlans((prev) => prev.filter((p) => p.id !== planId));
|
||||||
|
updateProgress((prev) => prev.filter((p) => p.planId !== planId));
|
||||||
|
}, [updateCustomPlans, updateProgress]);
|
||||||
|
|
||||||
|
// Patches an existing custom plan. Pass resetProgress: true when the edit
|
||||||
|
// changes its books/days (day indices from before the edit no longer mean
|
||||||
|
// anything), which restarts any in-progress runs of it from day 1.
|
||||||
|
const updateCustomPlan = useCallback((planId, patch, { resetProgress = false } = {}) => {
|
||||||
|
updateCustomPlans((prev) => prev.map((p) => (p.id === planId ? { ...p, ...patch } : p)));
|
||||||
|
if (resetProgress) {
|
||||||
|
updateProgress((prev) => prev.map((p) => (
|
||||||
|
p.planId === planId
|
||||||
|
? { ...p, completedDays: [], completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
|
||||||
|
: p
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}, [updateCustomPlans, updateProgress]);
|
||||||
|
|
||||||
|
// Server wins over whatever's local at sign-in.
|
||||||
|
const mergeServerReadingProgress = useCallback((serverData) => {
|
||||||
|
if (!serverData || typeof serverData !== 'object' || Array.isArray(serverData)) return;
|
||||||
|
const next = {
|
||||||
|
progress: Array.isArray(serverData.progress) ? serverData.progress : [],
|
||||||
|
customPlans: Array.isArray(serverData.customPlans) ? serverData.customPlans : [],
|
||||||
|
activity: Array.isArray(serverData.activity) ? serverData.activity : [],
|
||||||
|
};
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
|
||||||
|
setState(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
planProgress: state.progress,
|
||||||
|
customPlans: state.customPlans,
|
||||||
|
readingActivity: state.activity,
|
||||||
|
startPlan,
|
||||||
|
toggleDay,
|
||||||
|
restartPlan,
|
||||||
|
removePlanProgress,
|
||||||
|
recalibratePace,
|
||||||
|
addCustomPlan,
|
||||||
|
removeCustomPlan,
|
||||||
|
updateCustomPlan,
|
||||||
|
mergeServerReadingProgress,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,21 +4,23 @@ import AuthStatus from '../components/AuthStatus.jsx';
|
|||||||
import AudioPlayer from '../components/AudioPlayer.jsx';
|
import AudioPlayer from '../components/AudioPlayer.jsx';
|
||||||
import {
|
import {
|
||||||
bookOptions,
|
bookOptions,
|
||||||
CHAPTER_COUNTS,
|
|
||||||
formatChunkReference,
|
formatChunkReference,
|
||||||
formatRelativeDate,
|
formatRelativeDate,
|
||||||
loadProjectIndex,
|
loadProjectIndex,
|
||||||
} from '../utils/bibleUtils.jsx';
|
} from '../utils/bibleUtils.jsx';
|
||||||
import { loadProjectById } from '../utils/projectStorage.js';
|
import { loadProjectById } from '../utils/projectStorage.js';
|
||||||
|
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const {
|
const {
|
||||||
autoRestoredCount,
|
autoRestoredCount,
|
||||||
staleLocalProjects,
|
staleLocalProjects,
|
||||||
projectIndex,
|
projectIndex,
|
||||||
readingPlan,
|
planProgress,
|
||||||
createReadingPlan,
|
readingActivity,
|
||||||
clearReadingPlan,
|
toggleDay,
|
||||||
|
openReadingPlans,
|
||||||
|
readReadingPlanDay,
|
||||||
audioBook, setAudioBook,
|
audioBook, setAudioBook,
|
||||||
audioNarrator, setAudioNarrator,
|
audioNarrator, setAudioNarrator,
|
||||||
audioState,
|
audioState,
|
||||||
@@ -99,6 +101,14 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
📖 Read Bible
|
📖 Read Bible
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openReadingPlans}
|
||||||
|
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">📅 Reading Plans</span>
|
||||||
|
<span className="sm:hidden">📅 Plans</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openImportProject}
|
onClick={openImportProject}
|
||||||
@@ -145,6 +155,15 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Reading plans summary — shown regardless of whether there are any study projects yet */}
|
||||||
|
<ReadingPlansSummaryCard
|
||||||
|
planProgress={planProgress}
|
||||||
|
readingActivity={readingActivity}
|
||||||
|
openReadingPlans={openReadingPlans}
|
||||||
|
readReadingPlanDay={readReadingPlanDay}
|
||||||
|
toggleDay={toggleDay}
|
||||||
|
/>
|
||||||
|
|
||||||
{projectIndex.length === 0 ? (
|
{projectIndex.length === 0 ? (
|
||||||
<div className="mx-auto max-w-xl rounded-3xl border border-dashed border-slate-300 bg-white p-10 text-center shadow-panel">
|
<div className="mx-auto max-w-xl rounded-3xl border border-dashed border-slate-300 bg-white p-10 text-center shadow-panel">
|
||||||
<p className="text-lg font-semibold text-slate-700">No projects yet</p>
|
<p className="text-lg font-semibold text-slate-700">No projects yet</p>
|
||||||
@@ -233,9 +252,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reading plan card */}
|
|
||||||
<ReadingPlanCard readingPlan={readingPlan} createReadingPlan={createReadingPlan} clearReadingPlan={clearReadingPlan} openBibleReader={openBibleReader} />
|
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
title="Listen to BSB Audio"
|
title="Listen to BSB Audio"
|
||||||
@@ -394,108 +410,97 @@ export default function HomePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReadingPlanCard({ readingPlan, createReadingPlan, clearReadingPlan, openBibleReader }) {
|
function ReadingPlansSummaryCard({ planProgress, readingActivity, openReadingPlans, readReadingPlanDay, toggleDay }) {
|
||||||
const [showForm, setShowForm] = useState(false);
|
const active = planProgress.filter((p) => !p.completedAt);
|
||||||
const [planBook, setPlanBook] = useState(bookOptions[0].abbrev);
|
const streak = computeStreak(readingActivity);
|
||||||
const [planWeeks, setPlanWeeks] = useState(4);
|
|
||||||
|
|
||||||
if (readingPlan) {
|
const streakBadge = streak.current > 0 && (
|
||||||
const pct = readingPlan.totalChapters > 0
|
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">
|
||||||
? Math.round((readingPlan.chaptersRead.length / readingPlan.totalChapters) * 100)
|
🔥 {streak.current} day{streak.current !== 1 ? 's' : ''}
|
||||||
: 0;
|
</span>
|
||||||
const daysLeft = Math.max(0, Math.ceil((readingPlan.targetDate - Date.now()) / 86400000));
|
);
|
||||||
const chapLeft = readingPlan.totalChapters - readingPlan.chaptersRead.length;
|
|
||||||
const paceNeeded = daysLeft > 0 ? (chapLeft / daysLeft).toFixed(1) : '—';
|
if (active.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 rounded-3xl border border-emerald-200 bg-emerald-50 p-5 shadow-panel">
|
<div className="mb-4 flex items-center justify-between gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div>
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600">Reading Plan</p>
|
|
||||||
<h3 className="mt-1 text-base font-semibold text-slate-900">{readingPlan.bookName}</h3>
|
|
||||||
<p className="mt-0.5 text-sm text-slate-600">
|
|
||||||
{readingPlan.chaptersRead.length} / {readingPlan.totalChapters} chapters · {daysLeft} day{daysLeft !== 1 ? 's' : ''} left · {paceNeeded} ch/day needed
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<p className="text-sm font-semibold text-slate-700">Reading Plans</p>
|
||||||
type="button"
|
{streakBadge}
|
||||||
onClick={openBibleReader}
|
|
||||||
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
|
||||||
>
|
|
||||||
Read
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { if (window.confirm('Clear reading plan?')) clearReadingPlan(); }}
|
|
||||||
className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Read through the Bible, the Gospels, Psalms, and more — one day at a time.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-emerald-200">
|
<button
|
||||||
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
|
type="button"
|
||||||
</div>
|
onClick={openReadingPlans}
|
||||||
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
|
className="shrink-0 rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
|
||||||
{readingPlan.chaptersRead.length > 0 && (
|
>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
Browse plans
|
||||||
Read: ch. {readingPlan.chaptersRead.slice(0, 12).join(', ')}{readingPlan.chaptersRead.length > 12 ? '…' : ''}
|
</button>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rows = active
|
||||||
|
.map((progress) => {
|
||||||
|
const plan = getPlan(progress.planId);
|
||||||
|
if (!plan) return null;
|
||||||
|
const schedule = getPlanSchedule(progress.planId);
|
||||||
|
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
|
||||||
|
const dayIndex = nextUnreadDayIndex(plan, progress.completedDays);
|
||||||
|
const refs = dayIndex != null ? schedule[dayIndex] : null;
|
||||||
|
return { progress, plan, pct, dayIndex, refs };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
|
<div className="mb-4 rounded-3xl border border-indigo-200 bg-indigo-50 p-5 shadow-panel">
|
||||||
{showForm ? (
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-slate-700">Read</span>
|
<h3 className="text-sm font-semibold text-slate-900">📅 Today's Reading</h3>
|
||||||
<select
|
{streakBadge}
|
||||||
value={planBook}
|
|
||||||
onChange={(e) => setPlanBook(e.target.value)}
|
|
||||||
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
|
||||||
>
|
|
||||||
{bookOptions.map((b) => (
|
|
||||||
<option key={b.abbrev} value={b.abbrev}>{b.name} ({CHAPTER_COUNTS[b.abbrev] ?? '?'} ch)</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span className="text-sm text-slate-600">in</span>
|
|
||||||
<select
|
|
||||||
value={planWeeks}
|
|
||||||
onChange={(e) => setPlanWeeks(Number(e.target.value))}
|
|
||||||
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
|
||||||
>
|
|
||||||
{[1,2,3,4,6,8,12,16,26,52].map((w) => <option key={w} value={w}>{w} week{w !== 1 ? 's' : ''}</option>)}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const book = bookOptions.find((b) => b.abbrev === planBook);
|
|
||||||
createReadingPlan(planBook, book?.name ?? planBook, planWeeks);
|
|
||||||
setShowForm(false);
|
|
||||||
}}
|
|
||||||
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
|
||||||
>
|
|
||||||
Start plan
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => setShowForm(false)} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<button type="button" onClick={openReadingPlans} className="text-xs font-semibold text-indigo-700 hover:underline">
|
||||||
<div className="flex items-center justify-between gap-3">
|
View all plans →
|
||||||
<div>
|
</button>
|
||||||
<p className="text-sm font-semibold text-slate-700">Reading Plan</p>
|
</div>
|
||||||
<p className="text-xs text-slate-500">Set a goal to read through a book, track chapters as you go.</p>
|
<div className="space-y-1.5">
|
||||||
|
{rows.map(({ progress, plan, pct, dayIndex, refs }) => (
|
||||||
|
<div key={progress.id} className="rounded-2xl bg-white px-4 py-2.5 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{refs ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={false}
|
||||||
|
onChange={() => toggleDay(progress.id, dayIndex, plan.days)}
|
||||||
|
className="h-4 w-4 shrink-0 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="shrink-0 text-emerald-500">✓</span>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 text-lg leading-none">{plan.icon ?? '📖'}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-slate-900">{plan.title}</p>
|
||||||
|
<p className="truncate text-xs text-slate-500">
|
||||||
|
{refs ? formatDayReadings(refs) : `${progress.completedDays.length} / ${plan.days} days`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{refs && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => readReadingPlanDay(refs[0].book, refs[0].chapter)}
|
||||||
|
className="shrink-0 rounded-xl bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500"
|
||||||
|
>
|
||||||
|
Read
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<div className="h-full rounded-full bg-indigo-400 transition-all" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
))}
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => setShowForm(true)}
|
|
||||||
className="shrink-0 rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
Set goal
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { formatCrossRef, speakOriginalWord } from '../utils/wordStudy.js';
|
|||||||
import AuthStatus from '../components/AuthStatus.jsx';
|
import AuthStatus from '../components/AuthStatus.jsx';
|
||||||
import AudioPlayer from '../components/AudioPlayer.jsx';
|
import AudioPlayer from '../components/AudioPlayer.jsx';
|
||||||
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
|
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
|
||||||
|
import { getPlan, getPlanSchedule, findDayIndexForRef } from '../utils/readingPlans.js';
|
||||||
import DrawCanvas from './DrawCanvas.jsx';
|
import DrawCanvas from './DrawCanvas.jsx';
|
||||||
|
|
||||||
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
|
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
|
||||||
@@ -57,11 +58,43 @@ export default function ReaderPage() {
|
|||||||
readerTextHighlights,
|
readerTextHighlights,
|
||||||
addTextHighlight,
|
addTextHighlight,
|
||||||
removeTextHighlight,
|
removeTextHighlight,
|
||||||
readingPlan,
|
planProgress,
|
||||||
markChapterRead,
|
toggleDay,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
|
||||||
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
|
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
|
||||||
|
|
||||||
|
// Active reading plans whose schedule includes the chapter currently on screen.
|
||||||
|
const planMatches = useMemo(() => {
|
||||||
|
return planProgress
|
||||||
|
.filter((p) => !p.completedAt)
|
||||||
|
.map((p) => {
|
||||||
|
const plan = getPlan(p.planId);
|
||||||
|
if (!plan) return null;
|
||||||
|
const dayIndex = findDayIndexForRef(getPlanSchedule(p.planId), readerBookAbbrev, readerChapter);
|
||||||
|
if (dayIndex === -1) return null;
|
||||||
|
return { progressId: p.id, plan, dayIndex, done: p.completedDays.includes(dayIndex) };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}, [planProgress, readerBookAbbrev, readerChapter]);
|
||||||
|
|
||||||
|
// Auto-marks matching plan days as read after a dwell period on the chapter,
|
||||||
|
// so you don't have to remember to tap "Mark read" yourself. Resets whenever
|
||||||
|
// you navigate to a different book/chapter; a manual unmark isn't re-marked.
|
||||||
|
const planMatchesRef = useRef(planMatches);
|
||||||
|
planMatchesRef.current = planMatches;
|
||||||
|
const [autoMarkedNotice, setAutoMarkedNotice] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
const justMarked = planMatchesRef.current.filter((m) => !m.done);
|
||||||
|
if (justMarked.length === 0) return;
|
||||||
|
justMarked.forEach((m) => toggleDay(m.progressId, m.dayIndex, m.plan.days));
|
||||||
|
setAutoMarkedNotice(`✓ Marked read — ${justMarked.map((m) => m.plan.title).join(', ')}`);
|
||||||
|
window.setTimeout(() => setAutoMarkedNotice(''), 4000);
|
||||||
|
}, 20000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [readerBookAbbrev, readerChapter, toggleDay]);
|
||||||
|
|
||||||
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
||||||
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
|
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
|
||||||
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
|
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
|
||||||
@@ -316,19 +349,17 @@ export default function ReaderPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{readingPlan && readingPlan.bookAbbrev === readerBookAbbrev && (() => {
|
{planMatches.map(({ progressId, plan, dayIndex, done }) => (
|
||||||
const done = readingPlan.chaptersRead.includes(readerChapter);
|
<button
|
||||||
return (
|
key={progressId}
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => toggleDay(progressId, dayIndex, plan.days)}
|
||||||
onClick={() => markChapterRead(readerChapter)}
|
className={`rounded-xl px-3 py-1.5 text-sm font-semibold transition ${done ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'border border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-50'}`}
|
||||||
className={`rounded-xl px-3 py-1.5 text-sm font-semibold transition ${done ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'border border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-50'}`}
|
title={done ? `Click to unmark Day ${dayIndex + 1} of "${plan.title}"` : `Mark Day ${dayIndex + 1} of "${plan.title}" as read`}
|
||||||
title={done ? 'Click to unmark' : 'Mark this chapter as read in your reading plan'}
|
>
|
||||||
>
|
{done ? `✓ ${plan.title}` : `Mark read — ${plan.title}`}
|
||||||
{done ? '✓ Read' : 'Mark as read'}
|
</button>
|
||||||
</button>
|
))}
|
||||||
);
|
|
||||||
})()}
|
|
||||||
<button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1}
|
<button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1}
|
||||||
className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
||||||
‹ Prev
|
‹ Prev
|
||||||
@@ -340,6 +371,12 @@ export default function ReaderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{autoMarkedNotice && (
|
||||||
|
<div className="mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-700">
|
||||||
|
{autoMarkedNotice}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tool strip: font size · cross-refs · search */}
|
{/* Tool strip: font size · cross-refs · search */}
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-3xl border border-slate-200 bg-white px-4 py-3 shadow-panel">
|
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-3xl border border-slate-200 bg-white px-4 py-3 shadow-panel">
|
||||||
<span className="text-xs text-slate-500 mr-1">Text size</span>
|
<span className="text-xs text-slate-500 mr-1">Text size</span>
|
||||||
|
|||||||
@@ -0,0 +1,739 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import AuthStatus from '../components/AuthStatus.jsx';
|
||||||
|
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../utils/bibleUtils.jsx';
|
||||||
|
import {
|
||||||
|
READING_PLANS,
|
||||||
|
getPlan,
|
||||||
|
getPlanSchedule,
|
||||||
|
formatDayReadings,
|
||||||
|
firstRefOfDay,
|
||||||
|
nextUnreadDayIndex,
|
||||||
|
themeForCategory,
|
||||||
|
computeStreak,
|
||||||
|
daysBehindPace,
|
||||||
|
bookChapterCount,
|
||||||
|
ALL_BOOKS,
|
||||||
|
OT_BOOKS,
|
||||||
|
NT_BOOKS,
|
||||||
|
GOSPELS,
|
||||||
|
PENTATEUCH,
|
||||||
|
WISDOM_BOOKS,
|
||||||
|
} from '../utils/readingPlans.js';
|
||||||
|
|
||||||
|
export default function ReadingPlansPage() {
|
||||||
|
const {
|
||||||
|
goHome,
|
||||||
|
planProgress,
|
||||||
|
customPlans,
|
||||||
|
readingActivity,
|
||||||
|
startPlan,
|
||||||
|
toggleDay,
|
||||||
|
restartPlan,
|
||||||
|
removePlanProgress,
|
||||||
|
recalibratePace,
|
||||||
|
addCustomPlan,
|
||||||
|
removeCustomPlan,
|
||||||
|
updateCustomPlan,
|
||||||
|
readReadingPlanDay,
|
||||||
|
} = useApp();
|
||||||
|
const [builderMode, setBuilderMode] = useState(null); // null | 'new' | a custom plan id to edit
|
||||||
|
|
||||||
|
const activeProgress = planProgress.filter((p) => !p.completedAt);
|
||||||
|
const completedProgress = planProgress
|
||||||
|
.filter((p) => p.completedAt)
|
||||||
|
.sort((a, b) => b.completedAt - a.completedAt);
|
||||||
|
const streak = computeStreak(readingActivity);
|
||||||
|
const editingPlan = builderMode && builderMode !== 'new' ? customPlans.find((p) => p.id === builderMode) : null;
|
||||||
|
|
||||||
|
const categoryOrder = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical'];
|
||||||
|
const builtinCategories = categoryOrder.filter((c) => READING_PLANS.some((p) => p.category === c));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
|
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-semibold">Reading Plans</h1>
|
||||||
|
{streak.current > 0 && (
|
||||||
|
<p className="mt-1 text-sm text-amber-300">
|
||||||
|
🔥 {streak.current} day{streak.current !== 1 ? 's' : ''} in a row
|
||||||
|
{streak.longest > streak.current && ` · best: ${streak.longest}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goHome}
|
||||||
|
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<AuthStatus />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-5xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
{activeProgress.length > 0 && (
|
||||||
|
<TodayPanel activeProgress={activeProgress} onRead={readReadingPlanDay} onToggleDay={toggleDay} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeProgress.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold text-slate-900">Continue Reading</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{activeProgress.map((progress) => (
|
||||||
|
<ActivePlanCard
|
||||||
|
key={progress.id}
|
||||||
|
progress={progress}
|
||||||
|
onRead={readReadingPlanDay}
|
||||||
|
onToggleDay={toggleDay}
|
||||||
|
onRestart={restartPlan}
|
||||||
|
onRemove={removePlanProgress}
|
||||||
|
onRecalibrate={recalibratePace}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">My Plans</h2>
|
||||||
|
{builderMode === null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBuilderMode('new')}
|
||||||
|
className="rounded-xl bg-rose-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-rose-500"
|
||||||
|
>
|
||||||
|
+ New Plan
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{builderMode !== null && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<CustomPlanBuilder
|
||||||
|
key={builderMode}
|
||||||
|
initialValues={editingPlan}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
if (editingPlan) {
|
||||||
|
const booksChanged = JSON.stringify(values.books) !== JSON.stringify(editingPlan.books);
|
||||||
|
const daysChanged = values.days !== editingPlan.days;
|
||||||
|
const rangesChanged = JSON.stringify(values.chapterRanges ?? {}) !== JSON.stringify(editingPlan.chapterRanges ?? {});
|
||||||
|
updateCustomPlan(editingPlan.id, {
|
||||||
|
title: values.title.trim(),
|
||||||
|
description: values.description.trim(),
|
||||||
|
days: Math.max(1, Math.round(values.days)),
|
||||||
|
books: values.books,
|
||||||
|
chapterRanges: values.chapterRanges && Object.keys(values.chapterRanges).length > 0 ? values.chapterRanges : undefined,
|
||||||
|
image: values.image || undefined,
|
||||||
|
}, { resetProgress: booksChanged || daysChanged || rangesChanged });
|
||||||
|
} else {
|
||||||
|
addCustomPlan(values);
|
||||||
|
}
|
||||||
|
setBuilderMode(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setBuilderMode(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customPlans.length > 0 ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{customPlans.map((plan) => (
|
||||||
|
<PlanCatalogCard
|
||||||
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
progressForPlan={planProgress.filter((p) => p.planId === plan.id)}
|
||||||
|
onStart={startPlan}
|
||||||
|
onEdit={(id) => setBuilderMode(id)}
|
||||||
|
onDelete={(id) => { if (window.confirm('Delete this plan? Its progress history will be removed too.')) removeCustomPlan(id); }}
|
||||||
|
onImageChange={(id, image) => updateCustomPlan(id, { image })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : builderMode === null && (
|
||||||
|
<p className="text-sm text-slate-500">You haven't created any plans yet — try "+ New Plan".</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{builtinCategories.map((category) => (
|
||||||
|
<section key={category}>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold text-slate-900">{category}</h2>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{READING_PLANS.filter((p) => p.category === category).map((plan) => (
|
||||||
|
<PlanCatalogCard
|
||||||
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
progressForPlan={planProgress.filter((p) => p.planId === plan.id)}
|
||||||
|
onStart={startPlan}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{completedProgress.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold text-slate-900">Completed</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{completedProgress.map((progress) => {
|
||||||
|
const plan = getPlan(progress.planId);
|
||||||
|
if (!plan) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={progress.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-3 shadow-panel"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{plan.icon ?? '✓'} {plan.title}</p>
|
||||||
|
<p className="text-xs text-slate-500">Completed {formatRelativeDate(progress.completedAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => restartPlan(progress.id)}
|
||||||
|
className="font-semibold text-slate-700 hover:underline"
|
||||||
|
>
|
||||||
|
Start again
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { if (window.confirm('Remove this from your history?')) removePlanProgress(progress.id); }}
|
||||||
|
className="text-rose-500 hover:text-rose-700"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single combined "what's left to read today, across every active plan" list —
|
||||||
|
// the fast-glance view; ActivePlanCard below still has full detail/expand/restart.
|
||||||
|
function TodayPanel({ activeProgress, onRead, onToggleDay }) {
|
||||||
|
const rows = activeProgress
|
||||||
|
.map((progress) => {
|
||||||
|
const plan = getPlan(progress.planId);
|
||||||
|
if (!plan) return null;
|
||||||
|
const schedule = getPlanSchedule(progress.planId);
|
||||||
|
const dayIndex = nextUnreadDayIndex(plan, progress.completedDays);
|
||||||
|
if (dayIndex == null) return null;
|
||||||
|
const refs = schedule[dayIndex];
|
||||||
|
return { progress, plan, dayIndex, refs, ref: firstRefOfDay(refs) };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-3xl border border-indigo-200 bg-indigo-50 p-5 shadow-panel">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold text-slate-900">📅 Today</h2>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{rows.map(({ progress, plan, dayIndex, refs, ref }) => (
|
||||||
|
<div key={progress.id} className="flex flex-wrap items-center gap-3 rounded-2xl bg-white px-4 py-2.5 shadow-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={false}
|
||||||
|
onChange={() => onToggleDay(progress.id, dayIndex, plan.days)}
|
||||||
|
className="h-4 w-4 shrink-0 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 text-lg leading-none">{plan.icon ?? '📖'}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-slate-900">{plan.title}</p>
|
||||||
|
<p className="truncate text-xs text-slate-500">{formatDayReadings(refs)}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRead(ref.book, ref.chapter)}
|
||||||
|
className="shrink-0 rounded-xl bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500"
|
||||||
|
>
|
||||||
|
Read
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanCover({ plan }) {
|
||||||
|
const [imgFailed, setImgFailed] = useState(false);
|
||||||
|
const theme = themeForCategory(plan.category);
|
||||||
|
const showImage = !!plan.image && !imgFailed;
|
||||||
|
return (
|
||||||
|
<div className={`relative flex h-32 items-center justify-center overflow-hidden bg-gradient-to-br ${theme.gradient}`}>
|
||||||
|
{showImage && (
|
||||||
|
<img
|
||||||
|
src={plan.image}
|
||||||
|
alt=""
|
||||||
|
onError={() => setImgFailed(true)}
|
||||||
|
style={{ objectPosition: plan.imageFocus ?? 'center' }}
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!showImage && <span className="text-4xl drop-shadow">{plan.icon ?? '📖'}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove, onRecalibrate }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const plan = getPlan(progress.planId);
|
||||||
|
if (!plan) return null;
|
||||||
|
|
||||||
|
const theme = themeForCategory(plan.category);
|
||||||
|
const schedule = getPlanSchedule(progress.planId);
|
||||||
|
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
|
||||||
|
const nextDay = nextUnreadDayIndex(plan, progress.completedDays);
|
||||||
|
const nextRefs = nextDay != null ? schedule[nextDay] : null;
|
||||||
|
const nextFirstRef = nextRefs ? firstRefOfDay(nextRefs) : null;
|
||||||
|
const behind = daysBehindPace(plan, progress);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-3xl border border-emerald-200 bg-emerald-50 shadow-panel">
|
||||||
|
<div className={`h-1.5 w-full bg-gradient-to-r ${theme.gradient}`} />
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className={`text-xs font-semibold uppercase tracking-widest ${theme.text}`}>{plan.category}</p>
|
||||||
|
<h3 className="mt-1 text-base font-semibold text-slate-900">{plan.icon ?? '📖'} {plan.title}</h3>
|
||||||
|
<p className="mt-0.5 text-sm text-slate-600">
|
||||||
|
{progress.completedDays.length} / {plan.days} days
|
||||||
|
{nextRefs && <> · Next: Day {nextDay + 1} — {formatDayReadings(nextRefs)}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{nextFirstRef && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRead(nextFirstRef.book, nextFirstRef.chapter)}
|
||||||
|
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
||||||
|
>
|
||||||
|
Read
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((e) => !e)}
|
||||||
|
className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
|
||||||
|
>
|
||||||
|
{expanded ? 'Hide days' : 'View days'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-emerald-200">
|
||||||
|
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
|
||||||
|
|
||||||
|
{behind >= 2 && (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs">
|
||||||
|
<span className="text-amber-700">📅 {behind} days behind pace</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRecalibrate(progress.id)}
|
||||||
|
className="font-semibold text-amber-800 hover:underline"
|
||||||
|
>
|
||||||
|
Catch me up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-3 max-h-72 overflow-y-auto rounded-2xl border border-emerald-100 bg-white p-2">
|
||||||
|
{schedule.map((dayRefs, i) => {
|
||||||
|
const done = progress.completedDays.includes(i);
|
||||||
|
const ref = firstRefOfDay(dayRefs);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-slate-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={done}
|
||||||
|
onChange={() => onToggleDay(progress.id, i, plan.days)}
|
||||||
|
className="h-4 w-4 shrink-0 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
<span className={done ? 'flex-1 text-slate-400 line-through' : 'flex-1 text-slate-700'}>
|
||||||
|
Day {i + 1} — {formatDayReadings(dayRefs)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRead(ref.book, ref.chapter)}
|
||||||
|
className="shrink-0 text-xs font-semibold text-sky-600 hover:underline"
|
||||||
|
>
|
||||||
|
Read
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-4 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { if (window.confirm('Restart this plan from day 1?')) onRestart(progress.id); }}
|
||||||
|
className="font-semibold text-slate-500 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { if (window.confirm('Remove this plan?')) onRemove(progress.id); }}
|
||||||
|
className="font-semibold text-rose-500 hover:text-rose-700"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanCatalogCard({ plan, progressForPlan, onStart, onEdit, onDelete, onImageChange }) {
|
||||||
|
const active = progressForPlan.find((p) => !p.completedAt);
|
||||||
|
const completedCount = progressForPlan.filter((p) => p.completedAt).length;
|
||||||
|
const theme = themeForCategory(plan.category);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
const [imageError, setImageError] = useState('');
|
||||||
|
|
||||||
|
const pickImage = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = ''; // lets the same file be re-picked later
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
setImageError('');
|
||||||
|
onImageChange(plan.id, await resizeImageToDataUrl(file));
|
||||||
|
} catch {
|
||||||
|
setImageError("Couldn't use that image.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-panel transition hover:shadow-lg">
|
||||||
|
<PlanCover plan={plan} />
|
||||||
|
<div className="flex flex-col gap-2 p-5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">{plan.title}</h3>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{onEdit && (
|
||||||
|
<button type="button" onClick={() => onEdit(plan.id)} title="Edit plan" className="text-slate-300 transition hover:text-slate-600">
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(plan.id)}
|
||||||
|
title="Delete plan"
|
||||||
|
className="text-slate-300 transition hover:text-rose-500"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="flex-1 text-xs text-slate-500">{plan.description || 'A custom reading plan.'}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${theme.chip}`}>{plan.days} days</span>
|
||||||
|
{completedCount > 0 && <span className="text-xs font-semibold text-emerald-600">✓ Completed {completedCount}×</span>}
|
||||||
|
</div>
|
||||||
|
{onImageChange && (
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<button type="button" onClick={() => fileInputRef.current?.click()} className="font-semibold text-slate-500 hover:text-slate-700">
|
||||||
|
{plan.image ? 'Change cover' : '+ Add cover photo'}
|
||||||
|
</button>
|
||||||
|
{plan.image && (
|
||||||
|
<button type="button" onClick={() => onImageChange(plan.id, null)} className="text-slate-400 hover:text-rose-500">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{imageError && <p className="text-xs text-rose-500">{imageError}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!!active}
|
||||||
|
onClick={() => onStart(plan.id)}
|
||||||
|
className="mt-1 rounded-xl bg-slate-900 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{active ? 'In progress' : completedCount > 0 ? 'Start again' : 'Start plan'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downscales a picked image file to a reasonable cover-photo size and returns
|
||||||
|
// a data URL — no server upload endpoint needed, it's stored right on the plan.
|
||||||
|
function resizeImageToDataUrl(file, maxWidth = 960, quality = 0.82) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onerror = () => reject(new Error('Could not read file.'));
|
||||||
|
reader.onload = () => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onerror = () => reject(new Error('Could not decode image.'));
|
||||||
|
img.onload = () => {
|
||||||
|
const scale = Math.min(1, maxWidth / img.width);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.round(img.width * scale);
|
||||||
|
canvas.height = Math.round(img.height * scale);
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
resolve(canvas.toDataURL('image/jpeg', quality));
|
||||||
|
};
|
||||||
|
img.src = reader.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOK_PRESETS = [
|
||||||
|
['All', ALL_BOOKS],
|
||||||
|
['None', []],
|
||||||
|
['Old Testament', OT_BOOKS],
|
||||||
|
['New Testament', NT_BOOKS],
|
||||||
|
['Gospels', GOSPELS],
|
||||||
|
['Pentateuch', PENTATEUCH],
|
||||||
|
['Psalms', ['PSA']],
|
||||||
|
['Proverbs', ['PRO']],
|
||||||
|
['Wisdom', WISDOM_BOOKS],
|
||||||
|
];
|
||||||
|
|
||||||
|
function CustomPlanBuilder({ initialValues, onSubmit, onCancel }) {
|
||||||
|
const [title, setTitle] = useState(initialValues?.title ?? '');
|
||||||
|
const [description, setDescription] = useState(initialValues?.description ?? '');
|
||||||
|
const [days, setDays] = useState(initialValues?.days ?? 30);
|
||||||
|
const [books, setBooks] = useState(initialValues?.books ?? []);
|
||||||
|
const [chapterRanges, setChapterRanges] = useState(initialValues?.chapterRanges ?? {});
|
||||||
|
const [image, setImage] = useState(initialValues?.image ?? null);
|
||||||
|
const [imageError, setImageError] = useState('');
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
const isEditing = !!initialValues;
|
||||||
|
|
||||||
|
const toggleBook = (abbrev) => {
|
||||||
|
const isOn = books.includes(abbrev);
|
||||||
|
setBooks((prev) => (isOn ? prev.filter((b) => b !== abbrev) : [...prev, abbrev]));
|
||||||
|
if (isOn) {
|
||||||
|
setChapterRanges((prev) => {
|
||||||
|
if (!(abbrev in prev)) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[abbrev];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setChapterRange = (abbrev, key, value) => {
|
||||||
|
const total = CHAPTER_COUNTS[abbrev] ?? 1;
|
||||||
|
const clamped = Math.min(total, Math.max(1, Math.round(value) || 1));
|
||||||
|
setChapterRanges((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[abbrev]: { start: prev[abbrev]?.start ?? 1, end: prev[abbrev]?.end ?? total, [key]: clamped },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPreset = (list) => {
|
||||||
|
setBooks(list);
|
||||||
|
setChapterRanges({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickImage = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
setImageError('');
|
||||||
|
setImage(await resizeImageToDataUrl(file));
|
||||||
|
} catch {
|
||||||
|
setImageError("Couldn't use that image.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalChapters = books.reduce((sum, b) => sum + bookChapterCount(b, chapterRanges), 0);
|
||||||
|
const canCreate = title.trim().length > 0 && totalChapters > 0 && days >= 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-rose-200 bg-white p-5 shadow-panel">
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-slate-900">{isEditing ? 'Edit reading plan' : 'New reading plan'}</h3>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="text-sm text-slate-600">
|
||||||
|
Title
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g. Advent Readings"
|
||||||
|
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-200"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-slate-600">
|
||||||
|
Days
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="365"
|
||||||
|
value={days}
|
||||||
|
onChange={(e) => setDays(Number(e.target.value))}
|
||||||
|
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-200"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="mt-3 block text-sm text-slate-600">
|
||||||
|
Description <span className="text-slate-400">(optional)</span>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-200"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<span className="text-sm text-slate-600">Cover photo <span className="text-slate-400">(optional)</span></span>
|
||||||
|
<div className="mt-1 flex items-center gap-3">
|
||||||
|
{image ? (
|
||||||
|
<img src={image} alt="" className="h-16 w-28 rounded-lg object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-16 w-28 items-center justify-center rounded-lg bg-slate-100 text-2xl">🖼️</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-600 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{image ? 'Change photo' : 'Choose photo'}
|
||||||
|
</button>
|
||||||
|
{image && (
|
||||||
|
<button type="button" onClick={() => setImage(null)} className="text-sm text-slate-400 hover:text-rose-500">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
|
||||||
|
</div>
|
||||||
|
{imageError && <p className="mt-1 text-xs text-rose-500">{imageError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-slate-700">Books</span>
|
||||||
|
{BOOK_PRESETS.map(([label, list]) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset(list)}
|
||||||
|
className="rounded-full border border-slate-300 px-2.5 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-xs text-slate-400">
|
||||||
|
Check a book to include the whole thing, or narrow it to a chapter range with the fields that appear.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-xl border border-slate-200 p-2">
|
||||||
|
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-slate-400">Old Testament</p>
|
||||||
|
{bookOptions.filter((b) => OT_BOOKS.includes(b.abbrev)).map((b) => (
|
||||||
|
<BookRow
|
||||||
|
key={b.abbrev}
|
||||||
|
book={b}
|
||||||
|
checked={books.includes(b.abbrev)}
|
||||||
|
range={chapterRanges[b.abbrev]}
|
||||||
|
onToggle={() => toggleBook(b.abbrev)}
|
||||||
|
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-xl border border-slate-200 p-2">
|
||||||
|
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-slate-400">New Testament</p>
|
||||||
|
{bookOptions.filter((b) => NT_BOOKS.includes(b.abbrev)).map((b) => (
|
||||||
|
<BookRow
|
||||||
|
key={b.abbrev}
|
||||||
|
book={b}
|
||||||
|
checked={books.includes(b.abbrev)}
|
||||||
|
range={chapterRanges[b.abbrev]}
|
||||||
|
onToggle={() => toggleBook(b.abbrev)}
|
||||||
|
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-400">
|
||||||
|
{books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters
|
||||||
|
{days > 0 && totalChapters > 0 && ` · ~${(totalChapters / days).toFixed(1)} ch/day`}
|
||||||
|
</p>
|
||||||
|
{isEditing && (
|
||||||
|
books.join() !== initialValues.books.join()
|
||||||
|
|| Number(days) !== initialValues.days
|
||||||
|
|| JSON.stringify(chapterRanges) !== JSON.stringify(initialValues.chapterRanges ?? {})
|
||||||
|
) && (
|
||||||
|
<p className="mt-2 text-xs text-amber-600">
|
||||||
|
Changing the books, chapters, or day count restarts progress for any run of this plan already in your list.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canCreate}
|
||||||
|
onClick={() => onSubmit({ title, description, days, books, chapterRanges, image })}
|
||||||
|
className="rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isEditing ? 'Save changes' : 'Create plan'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onCancel} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One book row in the custom-plan builder: a checkbox, and — once checked — a
|
||||||
|
// "ch. X–Y" chapter-range pair that defaults to the whole book.
|
||||||
|
function BookRow({ book, checked, range, onToggle, onRangeChange }) {
|
||||||
|
const total = CHAPTER_COUNTS[book.abbrev] ?? 1;
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-slate-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onToggle}
|
||||||
|
className="h-4 w-4 shrink-0 rounded border-slate-300 text-rose-600 focus:ring-rose-500"
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{book.name}</span>
|
||||||
|
{checked && total > 1 && (
|
||||||
|
<span className="flex shrink-0 items-center gap-1 text-xs text-slate-500" onClick={(e) => e.preventDefault()}>
|
||||||
|
ch.
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={total}
|
||||||
|
value={range?.start ?? 1}
|
||||||
|
onChange={(e) => onRangeChange('start', e.target.value)}
|
||||||
|
className="w-11 rounded border border-slate-300 px-1 py-0.5 text-center"
|
||||||
|
/>
|
||||||
|
–
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={total}
|
||||||
|
value={range?.end ?? total}
|
||||||
|
onChange={(e) => onRangeChange('end', e.target.value)}
|
||||||
|
className="w-11 rounded border border-slate-300 px-1 py-0.5 text-center"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-400">/{total}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
disableMfa,
|
disableMfa,
|
||||||
changePassword,
|
changePassword,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
|
updateReminderSettings,
|
||||||
} from '../syncService.js';
|
} from '../syncService.js';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -28,6 +29,15 @@ export default function SettingsPage() {
|
|||||||
const [changePasswordBusy, setChangePasswordBusy] = useState(false);
|
const [changePasswordBusy, setChangePasswordBusy] = useState(false);
|
||||||
const [changePasswordError, setChangePasswordError] = useState('');
|
const [changePasswordError, setChangePasswordError] = useState('');
|
||||||
const [changePasswordSaved, setChangePasswordSaved] = useState(false);
|
const [changePasswordSaved, setChangePasswordSaved] = useState(false);
|
||||||
|
const [reminderForm, setReminderForm] = useState({
|
||||||
|
reminderEnabled: authUser?.reminderEnabled ?? false,
|
||||||
|
ntfyUrl: authUser?.ntfyUrl ?? '',
|
||||||
|
ntfyTopic: authUser?.ntfyTopic ?? '',
|
||||||
|
reminderTime: authUser?.reminderTime ?? '07:00',
|
||||||
|
});
|
||||||
|
const [reminderBusy, setReminderBusy] = useState(false);
|
||||||
|
const [reminderError, setReminderError] = useState('');
|
||||||
|
const [reminderSaved, setReminderSaved] = useState(false);
|
||||||
|
|
||||||
const startSetup = async () => {
|
const startSetup = async () => {
|
||||||
setMfaSetupError('');
|
setMfaSetupError('');
|
||||||
@@ -107,6 +117,21 @@ export default function SettingsPage() {
|
|||||||
window.setTimeout(() => setPodcastNameSaved(false), 2500);
|
window.setTimeout(() => setPodcastNameSaved(false), 2500);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitReminderSettings = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setReminderError('');
|
||||||
|
setReminderBusy(true);
|
||||||
|
const result = await updateReminderSettings(reminderForm);
|
||||||
|
setReminderBusy(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
setReminderError(result.error ?? 'Failed to save reminder settings.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAuthUser((u) => ({ ...u, ...result.data }));
|
||||||
|
setReminderSaved(true);
|
||||||
|
window.setTimeout(() => setReminderSaved(false), 2500);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||||
@@ -217,6 +242,71 @@ export default function SettingsPage() {
|
|||||||
{podcastNameSaved && <p className="text-sm text-emerald-600">Saved.</p>}
|
{podcastNameSaved && <p className="text-sm text-emerald-600">Saved.</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">Reading reminders</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Get a daily nudge via{' '}
|
||||||
|
<a href="https://ntfy.sh" target="_blank" rel="noreferrer" className="text-sky-600 underline">ntfy.sh</a>
|
||||||
|
{' '}— a free push-notification service. Install the ntfy app (or open{' '}
|
||||||
|
<span className="font-mono text-xs">{(reminderForm.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '')}/{reminderForm.ntfyTopic || '<topic>'}</span>
|
||||||
|
{' '}in a browser) and subscribe to the topic below to receive it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submitReminderSettings} className="space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={reminderForm.reminderEnabled}
|
||||||
|
onChange={(e) => setReminderForm((f) => ({ ...f, reminderEnabled: e.target.checked }))}
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
|
||||||
|
/>
|
||||||
|
Enable daily reading reminder
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block max-w-xs text-sm font-medium text-slate-700">
|
||||||
|
Topic
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. nate-bible-reading-8f2"
|
||||||
|
value={reminderForm.ntfyTopic}
|
||||||
|
onChange={(e) => setReminderForm((f) => ({ ...f, ntfyTopic: e.target.value }))}
|
||||||
|
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||||
|
/>
|
||||||
|
<span className="mt-1 block text-xs text-slate-400">Pick something unique — anyone who knows it can subscribe.</span>
|
||||||
|
</label>
|
||||||
|
<label className="block max-w-xs text-sm font-medium text-slate-700">
|
||||||
|
Reminder time
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={reminderForm.reminderTime}
|
||||||
|
onChange={(e) => setReminderForm((f) => ({ ...f, reminderTime: e.target.value }))}
|
||||||
|
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block max-w-xs text-sm font-medium text-slate-700">
|
||||||
|
ntfy server <span className="text-slate-400">(optional — defaults to ntfy.sh)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://ntfy.sh"
|
||||||
|
value={reminderForm.ntfyUrl}
|
||||||
|
onChange={(e) => setReminderForm((f) => ({ ...f, ntfyUrl: e.target.value }))}
|
||||||
|
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{reminderError && <p className="text-sm text-rose-600">{reminderError}</p>}
|
||||||
|
{reminderSaved && <p className="text-sm text-emerald-600">Saved.</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={reminderBusy}
|
||||||
|
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||||
|
>
|
||||||
|
{reminderBusy ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
|
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-slate-900">Two-factor authentication</h2>
|
<h2 className="text-lg font-semibold text-slate-900">Two-factor authentication</h2>
|
||||||
|
|||||||
@@ -65,6 +65,20 @@ export async function deleteRemoteProject(id) {
|
|||||||
return request('DELETE', `/projects/${id}`);
|
return request('DELETE', `/projects/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reading plan progress
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Returns the signed-in user's whole reading-plan progress list. */
|
||||||
|
export async function getRemoteReadingProgress() {
|
||||||
|
return request('GET', '/reading-plans');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overwrites the signed-in user's whole reading-plan progress list. */
|
||||||
|
export async function saveRemoteReadingProgress(progress) {
|
||||||
|
return request('PUT', '/reading-plans', progress);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Read-only share links
|
// Read-only share links
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -145,6 +159,11 @@ export async function changePassword(currentPassword, newPassword) {
|
|||||||
return request('POST', '/auth/change-password', { currentPassword, newPassword });
|
return request('POST', '/auth/change-password', { currentPassword, newPassword });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sets the daily reading reminder sent via ntfy.sh ({ reminderEnabled, ntfyUrl, ntfyTopic, reminderTime }). */
|
||||||
|
export async function updateReminderSettings(settings) {
|
||||||
|
return request('PATCH', '/auth/reminder-settings', settings);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Two-factor auth setup (Account Settings page)
|
// Two-factor auth setup (Account Settings page)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||