Version history, notes on highlights and bookmarks, "you've studied this", prayer journal
Build and Push Docker Image / build-and-push (push) Successful in 5m13s

- Earlier versions of a study: the server keeps compressed copies (about one per ten minutes of
  editing, the latest 30); Export shows what differs and puts one back (the replaced copy is kept)
- Notes on highlights and bookmarks: synced, merged without loss, in exports and backups
- Reader verse panel lists your studies and chunks that cover the verse, and opens them
- Prayer journal: requests, who for, linked verses, days prayed, answers; synced, in backups
  and the account export; daily prayer reminder through ntfy
- TODO.md lists the ideas not built yet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:45:32 -04:00
1 parent 54cb1c217d
commit 207bfcb919
45 files changed
+1669 -75

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
// The prayer journal: what you are praying about (and for whom), the verses tied to each request, the days you
// prayed, and the answers when they come.
import { useEffect, useMemo, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
import { useToast } from '../components/Toasts.jsx';
import { filterPrayers, daysToAnswer, labelFor, live, parseVerses, prayersToMarkdown } from '../utils/prayers.js';
import { localDateString } from '../utils/readingPlans.js';
const field = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const small = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1 text-xs hover:bg-black/5';
const tabBtn = (on) => `rounded-full border px-3 py-1 text-xs font-medium ${on ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
function VerseChips({ verses, onOpen }) {
if (!verses?.length) return null;
return (
<span className="mt-1 flex flex-wrap gap-1.5">
{verses.map((v, i) => (
<button key={`${v.book}${v.chapter}${v.verse}${i}`} type="button" onClick={() => onOpen(v.book, v.chapter, v.verse ?? 1)}
className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-accent)] hover:bg-black/5" title="Read it in the Bible">{labelFor(v)}</button>
))}
</span>
);
}
function PrayerCard({ prayer, today, actions, onOpenVerse }) {
const [mode, setMode] = useState(null); // null | 'edit' | 'answer'
const [text, setText] = useState('');
const [note, setNote] = useState('');
const prayedToday = prayer.prayedOn.includes(today);
const days = daysToAnswer(prayer);
return (
<li className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4">
{mode === 'edit' ? (
<div>
<textarea aria-label="Edit prayer" value={text} onChange={(e) => setText(e.target.value)} rows={3} maxLength={2000} className={`${field} w-full`} />
<div className="mt-2 flex gap-3 text-xs">
<button type="button" onClick={() => { if (text.trim()) actions.edit(prayer.id, { text: text.trim() }); setMode(null); }} className="font-medium text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setMode(null)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</div>
) : (
<div>
{prayer.forWhom && <p className="text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">For {prayer.forWhom}</p>}
<p className="whitespace-pre-wrap text-[15px] leading-relaxed">{prayer.text}</p>
<VerseChips verses={prayer.verses} onOpen={onOpenVerse} />
</div>
)}
{prayer.answeredAt && (
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note">
<p className="text-xs font-medium text-[var(--r-accent)]">Answered {new Date(prayer.answeredAt).toLocaleDateString()}{days != null ? ` · after ${days} day${days === 1 ? '' : 's'}` : ''}</p>
{prayer.answerNote && <p className="mt-0.5 whitespace-pre-wrap">{prayer.answerNote}</p>}
</div>
)}
{mode === 'answer' && (
<div className="mt-3">
<textarea aria-label="How was it answered?" autoFocus value={note} onChange={(e) => setNote(e.target.value)} rows={2} maxLength={1000} placeholder="How was it answered? (optional)" className={`${field} w-full`} />
<div className="mt-2 flex gap-3 text-xs">
<button type="button" onClick={() => { actions.answer(prayer.id, note); setMode(null); }} className="font-medium text-[var(--r-accent)] hover:underline">Mark answered</button>
<button type="button" onClick={() => setMode(null)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
{!prayer.answeredAt && (
<button type="button" onClick={() => actions.toggle(prayer.id)} aria-pressed={prayedToday} className={`${small} ${prayedToday ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>
{prayedToday ? '✓ Prayed today' : 'I prayed'}
</button>
)}
{prayer.prayedOn.length > 0 && <span className="text-xs text-[var(--r-sub)]">Prayed on {prayer.prayedOn.length} day{prayer.prayedOn.length === 1 ? '' : 's'}</span>}
<span className="ml-auto flex gap-2">
{prayer.answeredAt
? <button type="button" onClick={() => actions.reopen(prayer.id)} className={small}>Pray again</button>
: <button type="button" onClick={() => { setNote(''); setMode('answer'); }} className={small}>Answered</button>}
<button type="button" onClick={() => { setText(prayer.text); setMode('edit'); }} className={small}>Edit</button>
<button type="button" onClick={() => actions.remove(prayer.id)} className={`${small} text-[var(--r-danger,#b42318)]`}>Delete</button>
</span>
</div>
</li>
);
}
export default function PrayerPage() {
const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, prayerDraft, clearPrayerDraft, jumpToReaderVerse, openBibleReader } = useApp();
const { toast } = useToast();
const [tab, setTab] = useState('open');
const [query, setQuery] = useState('');
const [form, setForm] = useState({ text: '', forWhom: '', verses: '' });
const today = localDateString();
// "Pray about this verse" from the Reader arrives with the verse filled in
useEffect(() => {
if (prayerDraft) { setForm((f) => ({ ...f, verses: prayerDraft.label })); clearPrayerDraft(); }
}, [prayerDraft, clearPrayerDraft]);
const parsed = useMemo(() => parseVerses(form.verses), [form.verses]);
const shown = filterPrayers(prayers, { tab, query });
const counts = { open: filterPrayers(prayers, { tab: 'open' }).length, answered: filterPrayers(prayers, { tab: 'answered' }).length };
const add = (e) => {
e.preventDefault();
if (!form.text.trim()) return;
addPrayer({ text: form.text, forWhom: form.forWhom, verses: parsed.verses });
setForm({ text: '', forWhom: '', verses: '' });
setTab('open');
};
const actions = {
edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer,
answer: (id, note) => { answerPrayer(id, note); toast('Marked answered. Thank God for it.'); },
remove: (id) => { const undo = deletePrayer(id); toast('Prayer deleted', { action: { label: 'Undo', onClick: undo } }); },
};
const openVerse = (book, chapter, verse) => { jumpToReaderVerse(book, chapter, verse); openBibleReader(); };
const download = () => {
const url = URL.createObjectURL(new Blob([prayersToMarkdown(prayers)], { type: 'text/markdown;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = 'prayer-journal.md';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
return (
<PageShell title="Prayer journal" active="prayer" maxWidth="max-w-3xl">
<main className="mx-auto max-w-3xl space-y-5 px-4 py-6">
<form onSubmit={add} className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4" aria-label="Add a prayer">
<label className="block text-sm font-medium">What are you praying about?
<textarea value={form.text} onChange={(e) => setForm((f) => ({ ...f, text: e.target.value }))} rows={3} maxLength={2000} className={`${field} mt-1 w-full`} placeholder="A request, a thanks, someone to keep in prayer…" />
</label>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<label className="block text-xs text-[var(--r-sub)]">For (optional)
<input type="text" value={form.forWhom} onChange={(e) => setForm((f) => ({ ...f, forWhom: e.target.value }))} maxLength={80} placeholder="e.g. Sam, my church" className={`${field} mt-1 w-full`} />
</label>
<label className="block text-xs text-[var(--r-sub)]">Verses (optional)
<input type="text" value={form.verses} onChange={(e) => setForm((f) => ({ ...f, verses: e.target.value }))} placeholder="e.g. Philippians 4:6, Psalm 23" className={`${field} mt-1 w-full`} />
</label>
</div>
{(parsed.verses.length > 0 || parsed.unknown.length > 0) && (
<p className="mt-2 text-xs text-[var(--r-sub)]">
{parsed.verses.length > 0 && <>Linked: {parsed.verses.map(labelFor).join(' · ')}. </>}
{parsed.unknown.length > 0 && <span className="text-[var(--r-danger,#b42318)]">Couldn’t read: {parsed.unknown.join(', ')}.</span>}
</p>
)}
<button type="submit" disabled={!form.text.trim()} className="mt-3 rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">Add to my journal</button>
</form>
<div className="flex flex-wrap items-center gap-2">
<div role="tablist" aria-label="Which prayers" className="flex gap-1.5">
<button type="button" role="tab" aria-selected={tab === 'open'} onClick={() => setTab('open')} className={tabBtn(tab === 'open')}>Praying ({counts.open})</button>
<button type="button" role="tab" aria-selected={tab === 'answered'} onClick={() => setTab('answered')} className={tabBtn(tab === 'answered')}>Answered ({counts.answered})</button>
<button type="button" role="tab" aria-selected={tab === 'all'} onClick={() => setTab('all')} className={tabBtn(tab === 'all')}>All</button>
</div>
<input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search prayers…" aria-label="Search prayers" className={`${field} ml-auto w-48 py-1.5`} />
{live(prayers).length > 0 && <button type="button" onClick={download} className={small}>Download</button>}
</div>
{shown.length === 0 ? (
<p className="rounded-2xl border border-dashed border-[var(--r-line)] p-8 text-center text-sm text-[var(--r-sub)]">
{live(prayers).length === 0 ? 'Nothing here yet. Write down something you’re praying about, and come back to mark how God answers.'
: query ? 'No prayers match that search.' : tab === 'answered' ? 'No answered prayers yet.' : 'Nothing open right now.'}
</p>
) : (
<ul className="space-y-3" aria-label="Prayers">
{shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} />)}
</ul>
)}
</main>
</PageShell>
);
}