Add streaks, today view, custom plan editing/chapter ranges, and ntfy reminders
Build and Push Docker Image / build-and-push (push) Failing after 21s

Reading plans now track a cross-plan activity log for streaks, surface a
combined "today's reading" view on both Home and the Reading Plans page,
and let custom plans be edited after creation (including narrowing a book
to a specific chapter range). The reader auto-marks a plan's day read after
a dwell period, active plans that fall behind their pace get a "catch me
up" banner, and a new Settings section wires up daily reading reminders
via ntfy.sh, checked once a minute by a small server-side scheduler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-23 13:16:27 -04:00
1 parent 600b3d3ab9
commit 1dd6aef6a5
10 files changed
+711 -141

No files matched your search

+58 -7
View File
@@ -14,6 +14,7 @@ import {
setUserPassword, destroyAllSessionsForUser,
getAllReaderInk, setReaderInkPage,
getReadingProgress, setReadingProgress,
setReminderSettings,
} from './db.js';
import { SqliteSessionStore } from './sessionStore.js';
import {
@@ -21,6 +22,17 @@ import {
generateTotpSecret, totpKeyUri, verifyTotpToken,
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
} 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 app = express();
@@ -108,7 +120,10 @@ app.post('/api/auth/register', async (req, res) => {
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' });
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) {
console.error('POST /api/auth/register error:', err);
@@ -137,7 +152,7 @@ app.post('/api/auth/login', async (req, res) => {
return res.json({ mfaRequired: true });
}
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) {
console.error('POST /api/auth/login error:', err);
@@ -176,7 +191,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' });
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) {
console.error('POST /api/auth/mfa/verify error:', err);
@@ -194,10 +209,7 @@ app.post('/api/auth/logout', (req, res) => {
app.get('/api/auth/me', (req, res) => {
const user = req.session?.userId ? getUserById(req.session.userId) : null;
if (!user) return res.status(401).json({ error: 'Not signed in.' });
res.json({
id: user.id, email: user.email, totpEnabled: user.totpEnabled,
podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email),
});
res.json(userPayload(user));
});
// ---------------------------------------------------------------------------
@@ -214,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)
// ---------------------------------------------------------------------------
@@ -575,6 +625,7 @@ if (isProd) {
// Start
// ---------------------------------------------------------------------------
initDb();
startReminderScheduler();
app.listen(PORT, () => {
console.log(`Bible Study API running on http://localhost:${PORT}`);
if (isProd) {