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

+3
View File
@@ -28,6 +28,9 @@ export function cleanPrayer(raw, now = Date.now()) {
createdAt: time(raw.createdAt, updatedAt), updatedAt, createdAt: time(raw.createdAt, updatedAt), updatedAt,
answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null, answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null,
answerNote: text(raw.answerNote, 1000).trim(), answerNote: text(raw.answerNote, 1000).trim(),
status: raw.status === 'waiting' || raw.status === 'partial' ? raw.status : '',
pinned: raw.pinned === true,
rank: int(raw.rank, 0, 100_000) ? raw.rank : null,
}; };
} }
+5
View File
@@ -37,3 +37,8 @@ test('a deleted prayer is not brought back by an older copy, and old markers are
test('open prayers are the ones not answered or deleted', () => { test('open prayers are the ones not answered or deleted', () => {
expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']); expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']);
}); });
test('pin, rank and status are kept when valid and dropped when not', () => {
expect(cleanPrayer(p('a', { pinned: true, rank: 2, status: 'partial' }), NOW)).toMatchObject({ pinned: true, rank: 2, status: 'partial' });
expect(cleanPrayer(p('a', { pinned: 'yes', rank: -4, status: 'bogus' }), NOW)).toMatchObject({ pinned: false, rank: null, status: '' });
});
+37
View File
@@ -0,0 +1,37 @@
// Home card: the prayers you pinned, in the order you set, with a one-tap "I prayed". Hidden when nothing is pinned.
import { useApp } from '../context/AppContext.js';
import { STATUSES, pinned } from '../utils/prayers.js';
import { localDateString } from '../utils/readingPlans.js';
export default function PinnedPrayers() {
const { prayers, togglePrayed, openPrayer } = useApp();
const list = pinned(prayers).slice(0, 5);
if (!list.length) return null;
const today = localDateString();
return (
<section aria-label="Pinned prayers" className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="font-medium">Praying for</span>
<button type="button" onClick={openPrayer} className="text-xs text-[var(--r-accent)] hover:underline">Open journal →</button>
</div>
<ul className="space-y-2">
{list.map((p) => {
const done = p.prayedOn.includes(today);
return (
<li key={p.id} className="flex items-start gap-3">
<span className="min-w-0 flex-1">
{p.forWhom && <span className="font-medium">{p.forWhom}: </span>}
<span className="line-clamp-2">{p.text}</span>
{p.status && <span className="ml-2 text-xs text-[var(--r-sub)]">{STATUSES[p.status]}</span>}
</span>
<button type="button" onClick={() => togglePrayed(p.id)} aria-pressed={done} aria-label={`${done ? 'Prayed today' : 'I prayed'}: ${p.text.slice(0, 40)}`}
className={`shrink-0 rounded-lg border px-2.5 py-1 text-xs hover:bg-black/5 ${done ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
{done ? '✓ Prayed' : 'I prayed'}
</button>
</li>
);
})}
</ul>
</section>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
import { AppContext } from '../context/AppContext.js';
import PinnedPrayers from './PinnedPrayers.jsx';
import { markAnswered, newPrayer, setPinned } from '../utils/prayers.js';
const T0 = Date.UTC(2026, 9, 1);
const show = (prayers) => {
const ctx = { prayers, togglePrayed: vi.fn(), openPrayer: vi.fn() };
const view = render(<AppContext.Provider value={ctx}><PinnedPrayers /></AppContext.Provider>);
return { ctx, view };
};
test('shows nothing when no prayer is pinned', () => {
const { view } = show([newPrayer({ text: 'Unpinned' }, T0)]);
expect(view.container).toBeEmptyDOMElement();
});
test('lists open pinned prayers in order, lets you mark one prayed, and opens the journal', async () => {
const a = newPrayer({ text: 'For Sam', forWhom: 'Sam' }, T0);
const b = newPrayer({ text: 'Wisdom' }, T0 + 1);
const c = newPrayer({ text: 'Done already' }, T0 + 2);
let list = [a, b, c];
list = setPinned(setPinned(setPinned(list, b.id, true, T0 + 5), a.id, true, T0 + 6), c.id, true, T0 + 7);
list = list.map((p) => (p.id === c.id ? markAnswered(p, '', T0 + 9) : p));
const user = userEvent.setup();
const { ctx } = show(list);
const rows = screen.getAllByRole('listitem');
expect(rows).toHaveLength(2);
expect(rows[0]).toHaveTextContent('Wisdom');
expect(rows[1]).toHaveTextContent('Sam');
await user.click(screen.getAllByRole('button', { name: /I prayed/ })[0]);
expect(ctx.togglePrayed).toHaveBeenCalledWith(b.id);
await user.click(screen.getByRole('button', { name: /Open journal/ }));
expect(ctx.openPrayer).toHaveBeenCalled();
});
+5 -2
View File
@@ -3,7 +3,7 @@
// added on your phone appears on your computer. Signing out clears this device's copy. // added on your phone appears on your computer. Signing out clears this device's copy.
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { getRemotePrayers, saveRemotePrayers } from '../syncService.js'; import { getRemotePrayers, saveRemotePrayers } from '../syncService.js';
import { markAnswered, markDeleted, mergePrayers, newPrayer, prayersEqual, reopen, touch, togglePrayedToday } from '../utils/prayers.js'; import { markAnswered, markDeleted, mergePrayers, movePinned, newPrayer, prayersEqual, reopen, setPinned, setStatus, touch, togglePrayedToday } from '../utils/prayers.js';
import { localDateString } from '../utils/readingPlans.js'; import { localDateString } from '../utils/readingPlans.js';
const KEY = 'prayers'; const KEY = 'prayers';
@@ -42,6 +42,9 @@ export function usePrayers() {
const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString())); const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString()));
const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note)); const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note));
const reopenPrayer = (id) => replace(id, (p) => reopen(p)); const reopenPrayer = (id) => replace(id, (p) => reopen(p));
const pinPrayer = (id, on) => set(setPinned(ref.current, id, on));
const movePrayer = (id, dir) => set(movePinned(ref.current, id, dir));
const setPrayerStatus = (id, status, note) => replace(id, (p) => setStatus(p, status, note));
// Deleting leaves a marker so the other devices drop it too; the returned function puts it back // Deleting leaves a marker so the other devices drop it too; the returned function puts it back
const deletePrayer = (id) => { const deletePrayer = (id) => {
const before = ref.current.find((p) => p.id === id); const before = ref.current.find((p) => p.id === id);
@@ -70,5 +73,5 @@ export function usePrayers() {
}, []); }, []);
useEffect(() => () => window.clearTimeout(timerRef.current), []); useEffect(() => () => window.clearTimeout(timerRef.current), []);
return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop }; return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, pinPrayer, movePrayer, setPrayerStatus, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop };
} }
+2
View File
@@ -3,6 +3,7 @@ import { useApp } from '../context/AppContext.js';
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js'; import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
import BookCover from '../components/BookCover.jsx'; import BookCover from '../components/BookCover.jsx';
import DailyFive from '../components/DailyFive.jsx'; import DailyFive from '../components/DailyFive.jsx';
import PinnedPrayers from '../components/PinnedPrayers.jsx';
import WeeklyRecall from '../components/WeeklyRecall.jsx'; import WeeklyRecall from '../components/WeeklyRecall.jsx';
import OfflineBadge from '../components/OfflineBadge.jsx'; import OfflineBadge from '../components/OfflineBadge.jsx';
import { useOfflineSave } from '../hooks/useOfflineSave.js'; import { useOfflineSave } from '../hooks/useOfflineSave.js';
@@ -156,6 +157,7 @@ export default function HomePage() {
</p> </p>
); );
})()} })()}
<PinnedPrayers />
<DailyFive /> <DailyFive />
<WeeklyRecall /> <WeeklyRecall />
<RecallHistory /> <RecallHistory />
+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 // 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. // 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 { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx'; import PageShell from '../components/study/PageShell.jsx';
import { useToast } from '../components/Toasts.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'; 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 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 }) { function PrayerCard({ prayer, today, actions, onOpenVerse, pinPos }) {
const [mode, setMode] = useState(null); // null | 'edit' | 'answer' const [mode, setMode] = useState(null); // null | 'edit' | 'answer' | 'partial'
const [text, setText] = useState(''); const [text, setText] = useState('');
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const prayedToday = prayer.prayedOn.includes(today); const prayedToday = prayer.prayedOn.includes(today);
const isPinned = !!pinPos;
const days = daysToAnswer(prayer); const days = daysToAnswer(prayer);
return ( return (
<li className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4"> <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>
) : ( ) : (
<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> <p className="whitespace-pre-wrap text-[15px] leading-relaxed">{prayer.text}</p>
<VerseChips verses={prayer.verses} onOpen={onOpenVerse} /> <VerseChips verses={prayer.verses} onOpen={onOpenVerse} />
</div> </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 && ( {prayer.answeredAt && (
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note"> <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> <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>
</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"> <div className="mt-3 flex flex-wrap items-center gap-2">
{!prayer.answeredAt && ( {!prayer.answeredAt && (
<button type="button" onClick={() => actions.toggle(prayer.id)} aria-pressed={prayedToday} className={`${small} ${prayedToday ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}> <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> </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>} {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 {prayer.answeredAt
? <button type="button" onClick={() => actions.reopen(prayer.id)} className={small}>Pray again</button> ? <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={() => { setNote(''); setMode('answer'); }} className={small}>Answered</button>}
@@ -81,12 +116,13 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
} }
export default function PrayerPage() { 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 { toast } = useToast();
const [tab, setTab] = useState('open'); const [tab, setTab] = useState('open');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [form, setForm] = useState({ text: '', forWhom: '', verses: '' }); const [form, setForm] = useState({ text: '', forWhom: '', verses: '' });
const today = localDateString(); const today = localDateString();
const fileRef = useRef(null);
// "Pray about this verse" from the Reader arrives with the verse filled in // "Pray about this verse" from the Reader arrives with the verse filled in
useEffect(() => { useEffect(() => {
@@ -105,19 +141,31 @@ export default function PrayerPage() {
setTab('open'); setTab('open');
}; };
const actions = { 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.'); }, 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 } }); }, 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 openVerse = (book, chapter, verse) => { jumpToReaderVerse(book, chapter, verse); openBibleReader(); };
const download = () => { const pinOrder = useMemo(() => pinnedList(prayers).map((p) => p.id), [prayers]);
const url = URL.createObjectURL(new Blob([prayersToMarkdown(prayers)], { type: 'text/markdown;charset=utf-8' })); 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'); 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(); document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000); 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 ( return (
<PageShell title="Prayer journal" active="prayer" maxWidth="max-w-3xl"> <PageShell title="Prayer journal" active="prayer" maxWidth="max-w-3xl">
<main className="mx-auto max-w-3xl space-y-5 px-4 py-6"> <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> <button type="button" role="tab" aria-selected={tab === 'all'} onClick={() => setTab('all')} className={tabBtn(tab === 'all')}>All</button>
</div> </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`} /> <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> </div>
{shown.length === 0 ? ( {shown.length === 0 ? (
@@ -159,7 +214,7 @@ export default function PrayerPage() {
</p> </p>
) : ( ) : (
<ul className="space-y-3" aria-label="Prayers"> <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> </ul>
)} )}
</main> </main>
+32 -2
View File
@@ -4,14 +4,14 @@ import { vi, test, expect } from 'vitest';
import { AppContext } from '../context/AppContext.js'; import { AppContext } from '../context/AppContext.js';
import { ToastProvider } from '../components/Toasts.jsx'; import { ToastProvider } from '../components/Toasts.jsx';
import PrayerPage from './PrayerPage.jsx'; import PrayerPage from './PrayerPage.jsx';
import { markAnswered, newPrayer } from '../utils/prayers.js'; import { markAnswered, newPrayer, setPinned } from '../utils/prayers.js';
vi.mock('../components/study/PageShell.jsx', () => ({ default: ({ children }) => <div>{children}</div> })); vi.mock('../components/study/PageShell.jsx', () => ({ default: ({ children }) => <div>{children}</div> }));
const T0 = Date.UTC(2026, 9, 1); const T0 = Date.UTC(2026, 9, 1);
function setup(initial = [], extra = {}) { function setup(initial = [], extra = {}) {
const ctx = { const ctx = {
prayers: initial, addPrayer: vi.fn(), editPrayer: vi.fn(), togglePrayed: vi.fn(), answerPrayer: vi.fn(), reopenPrayer: vi.fn(), deletePrayer: vi.fn(() => vi.fn()), prayers: initial, addPrayer: vi.fn(), editPrayer: vi.fn(), togglePrayed: vi.fn(), answerPrayer: vi.fn(), reopenPrayer: vi.fn(), pinPrayer: vi.fn(), movePrayer: vi.fn(), setPrayerStatus: vi.fn(), importPrayers: vi.fn(), deletePrayer: vi.fn(() => vi.fn()),
prayerDraft: null, clearPrayerDraft: vi.fn(), jumpToReaderVerse: vi.fn(), openBibleReader: vi.fn(), ...extra, prayerDraft: null, clearPrayerDraft: vi.fn(), jumpToReaderVerse: vi.fn(), openBibleReader: vi.fn(), ...extra,
}; };
render(<AppContext.Provider value={ctx}><ToastProvider><PrayerPage /></ToastProvider></AppContext.Provider>); render(<AppContext.Provider value={ctx}><ToastProvider><PrayerPage /></ToastProvider></AppContext.Provider>);
@@ -78,3 +78,33 @@ test('search narrows the list', async () => {
expect(screen.getByText('Rain for the farm')).toBeInTheDocument(); expect(screen.getByText('Rain for the farm')).toBeInTheDocument();
expect(screen.queryByText('Peace at home')).toBeNull(); expect(screen.queryByText('Peace at home')).toBeNull();
}); });
test('prayers can be pinned, reordered, and marked waiting or partly answered', async () => {
const a = newPrayer({ text: 'First request' }, T0);
const b = newPrayer({ text: 'Second request' }, T0 + 1);
const list = setPinned(setPinned([a, b], a.id, true, T0 + 5), b.id, true, T0 + 6);
const user = userEvent.setup();
const ctx = setup(list);
const items = screen.getAllByRole('listitem');
expect(items[0]).toHaveTextContent('First request');
expect(within(items[0]).getByRole('button', { name: 'Move up' })).toBeDisabled();
await user.click(within(items[0]).getByRole('button', { name: 'Move down' }));
expect(ctx.movePrayer).toHaveBeenCalledWith(a.id, 1);
await user.click(within(items[1]).getByRole('button', { name: 'Unpin' }));
expect(ctx.pinPrayer).toHaveBeenCalledWith(b.id, false);
await user.click(within(items[0]).getByRole('button', { name: 'Waiting' }));
expect(ctx.setPrayerStatus).toHaveBeenCalledWith(a.id, 'waiting');
await user.click(within(items[0]).getByRole('button', { name: 'Partly answered' }));
await user.type(screen.getByLabelText('What has been answered so far?'), 'Job interview came');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(ctx.setPrayerStatus).toHaveBeenCalledWith(a.id, 'partial', 'Job interview came');
});
test('importing a file adds only new prayers', async () => {
const have = newPrayer({ text: 'Already here' }, T0);
const file = new File([JSON.stringify([{ text: 'Already here' }, { text: 'Brand new' }])], 'p.json', { type: 'application/json' });
const ctx = setup([have]);
await userEvent.setup().upload(screen.getByLabelText('Import prayers file'), file);
expect(ctx.importPrayers).toHaveBeenCalledWith([expect.objectContaining({ text: 'Brand new' })]);
expect(await screen.findByText(/Imported 1 prayer/)).toBeInTheDocument();
});
+127 -5
View File
@@ -1,7 +1,7 @@
// The prayer journal: requests you are praying about, the verses tied to them, the days you prayed, and how // The prayer journal: requests you are praying about, the verses tied to them, the days you prayed, and how
// God answered. Pure helpers for making, changing, merging and finding prayers. A prayer is // God answered. Pure helpers for making, changing, merging and finding prayers. A prayer is
// { id, text, forWhom, verses: [{ book, chapter, verse, label }], prayedOn: ['2026-10-06', ...], createdAt, updatedAt, // { id, text, forWhom, verses: [{ book, chapter, verse, label }], prayedOn: ['2026-10-06', ...], createdAt, updatedAt,
// answeredAt, answerNote }; a deleted one is just { id, deleted: true, updatedAt } so other devices drop it too. // answeredAt, answerNote, status: '' | 'waiting' | 'partial', pinned, rank }; a deleted one is just { id, deleted: true, updatedAt } so other devices drop it too.
import { formatPassage, parsePassageRef } from './passageRef.js'; import { formatPassage, parsePassageRef } from './passageRef.js';
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
@@ -10,7 +10,7 @@ const MAX_DAYS = 400;
const makeId = () => (globalThis.crypto?.randomUUID?.() ?? `p-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`); const makeId = () => (globalThis.crypto?.randomUUID?.() ?? `p-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`);
export function newPrayer({ text, forWhom = '', verses = [] }, now = Date.now()) { export function newPrayer({ text, forWhom = '', verses = [] }, now = Date.now()) {
return { id: makeId(), text: String(text).trim().slice(0, 2000), forWhom: String(forWhom).trim().slice(0, 80), verses: verses.slice(0, 8), prayedOn: [], createdAt: now, updatedAt: now, answeredAt: null, answerNote: '' }; return { id: makeId(), text: String(text).trim().slice(0, 2000), forWhom: String(forWhom).trim().slice(0, 80), verses: verses.slice(0, 8), prayedOn: [], createdAt: now, updatedAt: now, answeredAt: null, answerNote: '', status: '', pinned: false, rank: null };
} }
export const touch = (prayer, patch, now = Date.now()) => ({ ...prayer, ...patch, updatedAt: Math.max(now, (prayer.updatedAt ?? 0) + 1) }); export const touch = (prayer, patch, now = Date.now()) => ({ ...prayer, ...patch, updatedAt: Math.max(now, (prayer.updatedAt ?? 0) + 1) });
@@ -21,8 +21,33 @@ export function togglePrayedToday(prayer, today, now = Date.now()) {
return touch(prayer, { prayedOn: has ? prayer.prayedOn.filter((d) => d !== today) : [...prayer.prayedOn, today].sort().slice(-MAX_DAYS) }, now); return touch(prayer, { prayedOn: has ? prayer.prayedOn.filter((d) => d !== today) : [...prayer.prayedOn, today].sort().slice(-MAX_DAYS) }, now);
} }
export const markAnswered = (prayer, note, now = Date.now()) => touch(prayer, { answeredAt: now, answerNote: String(note ?? '').trim().slice(0, 1000) }, now); // Answering takes a prayer off the pinned list and clears any "waiting" / "partly answered" mark
export const reopen = (prayer, now = Date.now()) => touch(prayer, { answeredAt: null, answerNote: '' }, now); export const markAnswered = (prayer, note, now = Date.now()) => touch(prayer, { answeredAt: now, answerNote: String(note ?? '').trim().slice(0, 1000), status: '', pinned: false, rank: null }, now);
export const reopen = (prayer, now = Date.now()) => touch(prayer, { answeredAt: null, answerNote: '', status: '' }, now);
// Not a clean yes: "waiting" (still waiting on God) or "partial" (partly answered; the note says how). Both stay open.
export const STATUSES = { waiting: 'Waiting', partial: 'Partly answered' };
export function setStatus(prayer, status, note = '', now = Date.now()) {
const next = STATUSES[status] ? status : '';
return touch(prayer, { status: next, answerNote: next === 'partial' ? String(note ?? '').trim().slice(0, 1000) : '' }, now);
}
// Pinned prayers keep an order (`rank`, lowest first). Pinning puts one at the end; these return a new list.
export const pinned = (list) => live(list).filter((p) => p.pinned && !p.answeredAt).sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0) || (a.createdAt ?? 0) - (b.createdAt ?? 0));
export function setPinned(list, id, on, now = Date.now()) {
const top = pinned(list).reduce((m, p) => Math.max(m, p.rank ?? 0), 0);
return list.map((p) => (p.id === id && !p.deleted ? touch(p, on ? { pinned: true, rank: top + 1 } : { pinned: false, rank: null }, now) : p));
}
// dir: -1 moves the prayer up the pinned list, 1 moves it down
export function movePinned(list, id, dir, now = Date.now()) {
const order = pinned(list).map((p) => p.id);
const at = order.indexOf(id);
const to = at + dir;
if (at < 0 || to < 0 || to >= order.length) return list;
[order[at], order[to]] = [order[to], order[at]];
const rank = new Map(order.map((pid, i) => [pid, i + 1]));
return list.map((p) => (rank.has(p.id) && p.rank !== rank.get(p.id) ? touch(p, { rank: rank.get(p.id) }, now) : p));
}
// Same rules as the server's merge: the newer version of each prayer wins, days prayed are combined, deletions stick. // Same rules as the server's merge: the newer version of each prayer wins, days prayed are combined, deletions stick.
export function mergePrayers(a, b, now = Date.now()) { export function mergePrayers(a, b, now = Date.now()) {
@@ -59,7 +84,14 @@ export function filterPrayers(list, { tab = 'open', query = '' } = {}) {
return live(list) return live(list)
.filter((p) => (tab === 'open' ? !p.answeredAt : tab === 'answered' ? !!p.answeredAt : true)) .filter((p) => (tab === 'open' ? !p.answeredAt : tab === 'answered' ? !!p.answeredAt : true))
.filter((p) => !q || `${p.text} ${p.forWhom} ${p.answerNote} ${(p.verses ?? []).map((v) => v.label).join(' ')}`.toLowerCase().includes(q)) .filter((p) => !q || `${p.text} ${p.forWhom} ${p.answerNote} ${(p.verses ?? []).map((v) => v.label).join(' ')}`.toLowerCase().includes(q))
.sort((x, y) => (tab === 'answered' ? (y.answeredAt ?? 0) - (x.answeredAt ?? 0) : (y.updatedAt ?? 0) - (x.updatedAt ?? 0))); .sort((x, y) => {
if (tab === 'answered') return (y.answeredAt ?? 0) - (x.answeredAt ?? 0);
const px = x.pinned && !x.answeredAt;
const py = y.pinned && !y.answeredAt;
if (px !== py) return px ? -1 : 1;
if (px) return (x.rank ?? 0) - (y.rank ?? 0);
return (y.updatedAt ?? 0) - (x.updatedAt ?? 0);
});
} }
export const daysToAnswer = (p) => (p.answeredAt ? Math.max(0, Math.round((p.answeredAt - p.createdAt) / DAY_MS)) : null); export const daysToAnswer = (p) => (p.answeredAt ? Math.max(0, Math.round((p.answeredAt - p.createdAt) / DAY_MS)) : null);
@@ -73,6 +105,9 @@ export function prayersToMarkdown(list, { date = new Date() } = {}) {
for (const p of rows) { for (const p of rows) {
out.push(`- ${p.forWhom ? `**${p.forWhom}:** ` : ''}${p.text.replace(/\s*\n\s*/g, ' ')}${p.verses?.length ? ` (${p.verses.map(labelFor).join('; ')})` : ''}`); out.push(`- ${p.forWhom ? `**${p.forWhom}:** ` : ''}${p.text.replace(/\s*\n\s*/g, ' ')}${p.verses?.length ? ` (${p.verses.map(labelFor).join('; ')})` : ''}`);
out.push(` - Asked ${new Date(p.createdAt).toLocaleDateString()}${p.prayedOn?.length ? ` · prayed on ${p.prayedOn.length} day${p.prayedOn.length === 1 ? '' : 's'}` : ''}`); out.push(` - Asked ${new Date(p.createdAt).toLocaleDateString()}${p.prayedOn?.length ? ` · prayed on ${p.prayedOn.length} day${p.prayedOn.length === 1 ? '' : 's'}` : ''}`);
if (p.pinned && !p.answeredAt) out.push(' - Pinned');
if (p.status === 'waiting') out.push(' - Waiting');
if (p.status === 'partial') out.push(` - Partly answered${p.answerNote ? `: ${p.answerNote.replace(/\s*\n\s*/g, ' ')}` : ''}`);
if (p.answeredAt) out.push(` - Answered ${new Date(p.answeredAt).toLocaleDateString()}${p.answerNote ? `: ${p.answerNote.replace(/\s*\n\s*/g, ' ')}` : ''}`); if (p.answeredAt) out.push(` - Answered ${new Date(p.answeredAt).toLocaleDateString()}${p.answerNote ? `: ${p.answerNote.replace(/\s*\n\s*/g, ' ')}` : ''}`);
} }
out.push(''); out.push('');
@@ -82,3 +117,90 @@ export function prayersToMarkdown(list, { date = new Date() } = {}) {
if (!items.length) out.push('No prayers yet.', ''); if (!items.length) out.push('No prayers yet.', '');
return out.join('\n'); return out.join('\n');
} }
// ---- Import -------------------------------------------------------------------------------------------------
// Reads a file made by "Download" (markdown) or "Download JSON" (also a full backup, or a bare list of prayers).
// Returns { prayers, skipped } where `prayers` are new entries only: ones already in `existing` (same id, or the
// same words for the same person) are left out.
const key = (p) => `${String(p.forWhom ?? '').trim().toLowerCase()}|${String(p.text ?? '').trim().toLowerCase().replace(/\s+/g, ' ')}`;
const stamp = (v, fallback) => (Number.isFinite(v) && v > 0 ? v : fallback);
const dateMs = (str, fallback) => { const t = Date.parse(str); return Number.isFinite(t) ? t : fallback; };
function fromJson(raw, now) {
const rows = Array.isArray(raw) ? raw : Array.isArray(raw?.prayers) ? raw.prayers : null;
if (!rows) return null;
const out = [];
let skipped = 0;
for (const r of rows) {
if (!r || typeof r.text !== 'string' || !r.text.trim() || r.deleted) { skipped += 1; continue; }
const base = newPrayer({ text: r.text, forWhom: typeof r.forWhom === 'string' ? r.forWhom : '', verses: Array.isArray(r.verses) ? r.verses.filter((v) => v && typeof v.book === 'string' && Number.isInteger(v.chapter)) : [] }, now);
out.push({
...base,
id: typeof r.id === 'string' && /^[\w-]{1,64}$/.test(r.id) ? r.id : base.id,
prayedOn: (Array.isArray(r.prayedOn) ? r.prayedOn.filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)) : []).sort().slice(-MAX_DAYS),
createdAt: stamp(r.createdAt, now), updatedAt: stamp(r.updatedAt, now),
answeredAt: stamp(r.answeredAt, null), answerNote: typeof r.answerNote === 'string' ? r.answerNote.trim().slice(0, 1000) : '',
status: STATUSES[r.status] ? r.status : '', pinned: r.pinned === true && !r.answeredAt, rank: Number.isFinite(r.rank) ? r.rank : null,
});
}
return { prayers: out, skipped };
}
function fromMarkdown(text, now) {
const out = [];
let section = '';
let cur = null;
for (const line of String(text).split(/\r?\n/)) {
const head = line.match(/^##\s+(.+?)\s*$/);
if (head) { section = head[1].toLowerCase(); cur = null; continue; }
const item = line.match(/^- (?:\*\*(.+?):\*\* )?(.+)$/);
if (item && section) {
let body = item[2].trim();
let verses = [];
const tail = body.match(/\s\(([^()]*)\)$/);
if (tail) {
const r = parseVerses(tail[1]);
if (r.verses.length && !r.unknown.length) { verses = r.verses; body = body.slice(0, tail.index).trim(); }
}
cur = { ...newPrayer({ text: body, forWhom: item[1] ?? '', verses }, now) };
if (section === 'answered') cur.answeredAt = now;
out.push(cur);
continue;
}
const sub = cur && line.match(/^\s+- (.+)$/);
if (!sub) continue;
const m = sub[1];
let x;
if ((x = m.match(/^Asked (.+?)(?: · prayed on \d+ days?)?$/))) { cur.createdAt = dateMs(x[1], now); cur.updatedAt = cur.createdAt; }
else if ((x = m.match(/^Answered ([^:]+?)(?:: (.*))?$/))) { cur.answeredAt = dateMs(x[1], now); cur.answerNote = (x[2] ?? '').slice(0, 1000); }
else if (m === 'Pinned') { cur.pinned = !cur.answeredAt; }
else if (m === 'Waiting') cur.status = 'waiting';
else if ((x = m.match(/^Partly answered(?:: (.*))?$/))) { cur.status = 'partial'; cur.answerNote = (x[1] ?? '').slice(0, 1000); }
}
// assign ranks to pinned ones in file order
let rank = 0;
for (const p of out) if (p.pinned) { rank += 1; p.rank = rank; }
return { prayers: out, skipped: 0 };
}
export function parsePrayerImport(fileText, existing = [], now = Date.now()) {
const trimmed = String(fileText ?? '').trim();
let parsed = null;
if (/^[[{]/.test(trimmed)) { try { parsed = fromJson(JSON.parse(trimmed), now); } catch { parsed = null; } }
if (!parsed && !/^[[{]/.test(trimmed)) parsed = fromMarkdown(trimmed, now);
if (!parsed) return { prayers: [], skipped: 0, error: 'That file isn’t a prayer journal.' };
const have = live(existing);
const ids = new Set((existing ?? []).map((p) => p.id));
const seen = new Set(have.map(key));
const prayers = [];
let skipped = parsed.skipped;
for (const p of parsed.prayers) {
if (ids.has(p.id) || seen.has(key(p))) { skipped += 1; continue; }
seen.add(key(p));
prayers.push(p);
}
if (!prayers.length && !skipped) return { prayers, skipped, error: 'No prayers found in that file.' };
return { prayers, skipped };
}
export const prayersToJson = (list) => JSON.stringify({ kind: 'prayer-journal', version: 1, prayers: live(list) }, null, 2);
+49
View File
@@ -68,3 +68,52 @@ test('the Markdown separates what is being prayed for from what was answered', (
expect(md).toContain(': Hired!'); expect(md).toContain(': Hired!');
expect(prayersToMarkdown([])).toContain('No prayers yet.'); expect(prayersToMarkdown([])).toContain('No prayers yet.');
}); });
import { movePinned, parsePrayerImport, pinned, prayersToJson, setPinned, setStatus } from './prayers.js';
test('pinning orders prayers, moving swaps them, and answering unpins', () => {
let list = ['a', 'b', 'c'].map((t, i) => newPrayer({ text: t }, T0 + i));
const [a, b, c] = list.map((p) => p.id);
list = setPinned(list, b, true, T0 + 10);
list = setPinned(list, a, true, T0 + 11);
expect(pinned(list).map((p) => p.id)).toEqual([b, a]);
list = movePinned(list, a, -1, T0 + 12);
expect(pinned(list).map((p) => p.id)).toEqual([a, b]);
expect(movePinned(list, a, -1)).toBe(list); // already first
expect(filterPrayers(list, { tab: 'open' }).map((p) => p.id)[0]).toBe(a);
expect(filterPrayers(list, { tab: 'open' }).map((p) => p.id)).toEqual([a, b, c]);
list = list.map((p) => (p.id === a ? markAnswered(p, '', T0 + 20) : p));
expect(pinned(list).map((p) => p.id)).toEqual([b]);
});
test('waiting and partly answered stay open; partial keeps a note; answering clears them', () => {
let p = setStatus(newPrayer({ text: 'x' }, T0), 'partial', ' half way ', T0 + 1);
expect(p).toMatchObject({ status: 'partial', answerNote: 'half way', answeredAt: null });
p = setStatus(p, 'waiting', 'ignored', T0 + 2);
expect(p).toMatchObject({ status: 'waiting', answerNote: '' });
expect(setStatus(p, '', '', T0 + 3).status).toBe('');
expect(markAnswered(p, 'done', T0 + 4)).toMatchObject({ status: '', answeredAt: T0 + 4 });
});
test('import reads our markdown and JSON, and skips prayers already in the journal', () => {
const src = [
{ ...newPrayer({ text: 'Wisdom', forWhom: 'Sam', verses: [{ book: 'JAS', chapter: 1, verse: 5, label: 'James 1:5' }] }, T0), pinned: true, rank: 1, status: 'waiting' },
markAnswered(newPrayer({ text: 'A job' }, T0), 'Hired', T0 + DAY),
];
const md = prayersToMarkdown(src, { date: new Date(T0) });
const fromMd = parsePrayerImport(md, [], T0);
expect(fromMd.prayers).toHaveLength(2);
expect(fromMd.prayers[0]).toMatchObject({ text: 'Wisdom', forWhom: 'Sam', status: 'waiting', pinned: true, answeredAt: null });
expect(fromMd.prayers[0].verses[0]).toMatchObject({ book: 'JAS', chapter: 1, verse: 5 });
expect(fromMd.prayers[1]).toMatchObject({ text: 'A job', answerNote: 'Hired' });
expect(fromMd.prayers[1].answeredAt).toBeTruthy();
const fromJson = parsePrayerImport(prayersToJson(src), [], T0);
expect(fromJson.prayers.map((p) => p.id)).toEqual(src.map((p) => p.id));
expect(fromJson.prayers[0]).toMatchObject({ pinned: true, status: 'waiting' });
expect(parsePrayerImport(prayersToJson(src), [src[0]], T0)).toMatchObject({ skipped: 1, prayers: [expect.objectContaining({ text: 'A job' })] });
expect(parsePrayerImport(md, [src[0]], T0).prayers.map((p) => p.text)).toEqual(['A job']); // same words, same person
expect(parsePrayerImport('{"nope":1}', []).error).toBeTruthy();
expect(parsePrayerImport('', []).error).toBeTruthy();
});