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

+90
View File
@@ -7,6 +7,7 @@ import {
disableMfa,
changePassword,
updateProfile,
updateReminderSettings,
} from '../syncService.js';
export default function SettingsPage() {
@@ -28,6 +29,15 @@ export default function SettingsPage() {
const [changePasswordBusy, setChangePasswordBusy] = useState(false);
const [changePasswordError, setChangePasswordError] = useState('');
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 () => {
setMfaSetupError('');
@@ -107,6 +117,21 @@ export default function SettingsPage() {
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 (
<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">
@@ -217,6 +242,71 @@ export default function SettingsPage() {
{podcastNameSaved && <p className="text-sm text-emerald-600">Saved.</p>}
</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">
<div>
<h2 className="text-lg font-semibold text-slate-900">Two-factor authentication</h2>