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
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user