Prayer journal: pinning, waiting/partial status, import, dashboard card
Build and Push Docker Image / build-and-push (push) Successful in 5m14s
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:
1 parent
6d647e8d8a
commit
1cf157be64
10 files changed
+365
-22
No files matched your search
@@ -28,6 +28,9 @@ export function cleanPrayer(raw, now = Date.now()) {
|
||||
createdAt: time(raw.createdAt, updatedAt), updatedAt,
|
||||
answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
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: '' });
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
// added on your phone appears on your computer. Signing out clears this device's copy.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
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';
|
||||
|
||||
const KEY = 'prayers';
|
||||
@@ -42,6 +42,9 @@ export function usePrayers() {
|
||||
const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString()));
|
||||
const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note));
|
||||
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
|
||||
const deletePrayer = (id) => {
|
||||
const before = ref.current.find((p) => p.id === id);
|
||||
@@ -70,5 +73,5 @@ export function usePrayers() {
|
||||
}, []);
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useApp } from '../context/AppContext.js';
|
||||
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
||||
import BookCover from '../components/BookCover.jsx';
|
||||
import DailyFive from '../components/DailyFive.jsx';
|
||||
import PinnedPrayers from '../components/PinnedPrayers.jsx';
|
||||
import WeeklyRecall from '../components/WeeklyRecall.jsx';
|
||||
import OfflineBadge from '../components/OfflineBadge.jsx';
|
||||
import { useOfflineSave } from '../hooks/useOfflineSave.js';
|
||||
@@ -156,6 +157,7 @@ export default function HomePage() {
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
<PinnedPrayers />
|
||||
<DailyFive />
|
||||
<WeeklyRecall />
|
||||
<RecallHistory />
|
||||
|
||||
+68
-13
@@ -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>
|
||||
|
||||
@@ -4,14 +4,14 @@ import { vi, test, expect } from 'vitest';
|
||||
import { AppContext } from '../context/AppContext.js';
|
||||
import { ToastProvider } from '../components/Toasts.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> }));
|
||||
|
||||
const T0 = Date.UTC(2026, 9, 1);
|
||||
function setup(initial = [], extra = {}) {
|
||||
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,
|
||||
};
|
||||
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.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
@@ -1,7 +1,7 @@
|
||||
// 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
|
||||
// { 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';
|
||||
|
||||
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)}`);
|
||||
|
||||
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) });
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
export const markAnswered = (prayer, note, now = Date.now()) => touch(prayer, { answeredAt: now, answerNote: String(note ?? '').trim().slice(0, 1000) }, now);
|
||||
export const reopen = (prayer, now = Date.now()) => touch(prayer, { answeredAt: null, answerNote: '' }, now);
|
||||
// Answering takes a prayer off the pinned list and clears any "waiting" / "partly answered" mark
|
||||
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.
|
||||
export function mergePrayers(a, b, now = Date.now()) {
|
||||
@@ -59,7 +84,14 @@ export function filterPrayers(list, { tab = 'open', query = '' } = {}) {
|
||||
return live(list)
|
||||
.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))
|
||||
.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);
|
||||
@@ -73,6 +105,9 @@ export function prayersToMarkdown(list, { date = new Date() } = {}) {
|
||||
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(` - 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, ' ')}` : ''}`);
|
||||
}
|
||||
out.push('');
|
||||
@@ -82,3 +117,90 @@ export function prayersToMarkdown(list, { date = new Date() } = {}) {
|
||||
if (!items.length) out.push('No prayers yet.', '');
|
||||
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);
|
||||
@@ -68,3 +68,52 @@ test('the Markdown separates what is being prayed for from what was answered', (
|
||||
expect(md).toContain(': Hired!');
|
||||
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();
|
||||
});
|
||||
Reference in new issue
Block a user