Prayer journal: pinning, waiting/partial status, import, dashboard card
Build and Push Docker Image / build-and-push (push) Successful in 5m14s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 16:27:04 -04:00
1 parent 6d647e8d8a
commit 1cf157be64
10 files changed
+365 -22

No files matched your search

+68 -13
View File
@@ -1,10 +1,10 @@
// 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 { useEffect, useMemo, useRef, 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 { STATUSES, filterPrayers, daysToAnswer, labelFor, live, parsePrayerImport, parseVerses, pinned as pinnedList, prayersToJson, 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)]';
@@ -23,11 +23,12 @@ function VerseChips({ verses, onOpen }) {
);
}
function PrayerCard({ prayer, today, actions, onOpenVerse }) {
const [mode, setMode] = useState(null); // null | 'edit' | 'answer'
function PrayerCard({ prayer, today, actions, onOpenVerse, pinPos }) {
const [mode, setMode] = useState(null); // null | 'edit' | 'answer' | 'partial'
const [text, setText] = useState('');
const [note, setNote] = useState('');
const prayedToday = prayer.prayedOn.includes(today);
const isPinned = !!pinPos;
const days = daysToAnswer(prayer);
return (
<li className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4">
@@ -41,11 +42,23 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
</div>
) : (
<div>
{prayer.forWhom && <p className="text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">For {prayer.forWhom}</p>}
{(prayer.forWhom || isPinned || prayer.status) && (
<p className="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">
{isPinned && <span className="text-[var(--r-accent)]">Pinned</span>}
{prayer.status && <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 normal-case tracking-normal">{STATUSES[prayer.status]}</span>}
{prayer.forWhom && <span>For {prayer.forWhom}</span>}
</p>
)}
<p className="whitespace-pre-wrap text-[15px] leading-relaxed">{prayer.text}</p>
<VerseChips verses={prayer.verses} onOpen={onOpenVerse} />
</div>
)}
{prayer.status === 'partial' && prayer.answerNote && mode !== 'partial' && (
<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)]">Partly answered</p>
<p className="mt-0.5 whitespace-pre-wrap">{prayer.answerNote}</p>
</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>
@@ -61,6 +74,15 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
</div>
</div>
)}
{mode === 'partial' && (
<div className="mt-3">
<textarea aria-label="What has been answered so far?" autoFocus value={note} onChange={(e) => setNote(e.target.value)} rows={2} maxLength={1000} placeholder="What has been answered so far? (optional)" className={`${field} w-full`} />
<div className="mt-2 flex gap-3 text-xs">
<button type="button" onClick={() => { actions.status(prayer.id, 'partial', note); 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 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)]' : ''}`}>
@@ -68,7 +90,20 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
</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">
<span className="ml-auto flex flex-wrap gap-2">
{!prayer.answeredAt && isPinned && (
<>
<button type="button" onClick={() => actions.move(prayer.id, -1)} disabled={pinPos.index === 0} aria-label="Move up" title="Move up" className={`${small} disabled:opacity-40`}>↑</button>
<button type="button" onClick={() => actions.move(prayer.id, 1)} disabled={pinPos.index === pinPos.count - 1} aria-label="Move down" title="Move down" className={`${small} disabled:opacity-40`}>↓</button>
</>
)}
{!prayer.answeredAt && <button type="button" onClick={() => actions.pin(prayer.id, !isPinned)} aria-pressed={isPinned} className={`${small} ${isPinned ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>{isPinned ? 'Unpin' : 'Pin'}</button>}
{!prayer.answeredAt && (
<>
<button type="button" onClick={() => actions.status(prayer.id, prayer.status === 'waiting' ? '' : 'waiting')} aria-pressed={prayer.status === 'waiting'} className={`${small} ${prayer.status === 'waiting' ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>Waiting</button>
<button type="button" onClick={() => { setNote(prayer.status === 'partial' ? prayer.answerNote : ''); setMode('partial'); }} aria-pressed={prayer.status === 'partial'} className={`${small} ${prayer.status === 'partial' ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>Partly answered</button>
</>
)}
{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>}
@@ -81,12 +116,13 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
}
export default function PrayerPage() {
const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, prayerDraft, clearPrayerDraft, jumpToReaderVerse, openBibleReader } = useApp();
const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, pinPrayer, movePrayer, setPrayerStatus, importPrayers, 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();
const fileRef = useRef(null);
// "Pray about this verse" from the Reader arrives with the verse filled in
useEffect(() => {
@@ -105,19 +141,31 @@ export default function PrayerPage() {
setTab('open');
};
const actions = {
edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer,
edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer, pin: pinPrayer, move: movePrayer, status: setPrayerStatus,
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 pinOrder = useMemo(() => pinnedList(prayers).map((p) => p.id), [prayers]);
const download = (json = false) => {
const url = URL.createObjectURL(new Blob([json ? prayersToJson(prayers) : prayersToMarkdown(prayers)], { type: json ? 'application/json' : 'text/markdown;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = 'prayer-journal.md';
a.href = url; a.download = json ? 'prayer-journal.json' : 'prayer-journal.md';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const onImportFile = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
let result;
try { result = parsePrayerImport(await file.text(), prayers); } catch { result = { prayers: [], skipped: 0, error: 'Couldn’t read that file.' }; }
if (result.error) { toast(result.error); return; }
if (result.prayers.length) importPrayers(result.prayers);
toast(`Imported ${result.prayers.length} prayer${result.prayers.length === 1 ? '' : 's'}${result.skipped ? ` (${result.skipped} already here or skipped)` : ''}`);
};
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">
@@ -149,7 +197,14 @@ export default function PrayerPage() {
<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>}
<button type="button" onClick={() => fileRef.current?.click()} className={small}>Import</button>
<input ref={fileRef} type="file" accept=".json,.md,.txt,text/markdown,application/json,text/plain" onChange={onImportFile} className="hidden" aria-label="Import prayers file" />
{live(prayers).length > 0 && (
<>
<button type="button" onClick={() => download(false)} className={small}>Download</button>
<button type="button" onClick={() => download(true)} className={small} title="Keeps every detail, including the days you prayed">Download JSON</button>
</>
)}
</div>
{shown.length === 0 ? (
@@ -159,7 +214,7 @@ export default function PrayerPage() {
</p>
) : (
<ul className="space-y-3" aria-label="Prayers">
{shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} />)}
{shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} pinPos={pinOrder.includes(p.id) ? { index: pinOrder.indexOf(p.id), count: pinOrder.length } : null} />)}
</ul>
)}
</main>