From 1cf157be642f8106e8e198cf158a257905ec4fd1 Mon Sep 17 00:00:00 2001
From: nmemmert <67961292+nmemmert@users.noreply.github.com>
Date: Wed, 14 Oct 2026 16:27:04 -0400
Subject: [PATCH] Prayer journal: pinning, waiting/partial status, import,
dashboard card
Co-Authored-By: Claude Sonnet 5.5
---
server/prayers.js | 3 +
server/prayers.test.js | 5 +
src/components/PinnedPrayers.jsx | 37 ++++++++
src/components/PinnedPrayers.test.jsx | 37 ++++++++
src/hooks/usePrayers.js | 7 +-
src/pages/HomePage.jsx | 2 +
src/pages/PrayerPage.jsx | 81 +++++++++++++---
src/pages/PrayerPage.test.jsx | 34 ++++++-
src/utils/prayers.js | 132 +++++++++++++++++++++++++-
src/utils/prayers.test.js | 49 ++++++++++
10 files changed, 365 insertions(+), 22 deletions(-)
create mode 100644 src/components/PinnedPrayers.jsx
create mode 100644 src/components/PinnedPrayers.test.jsx
diff --git a/server/prayers.js b/server/prayers.js
index 233882a..4b1fa1a 100644
--- a/server/prayers.js
+++ b/server/prayers.js
@@ -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,
};
}
diff --git a/server/prayers.test.js b/server/prayers.test.js
index fe2d810..4d2b064 100644
--- a/server/prayers.test.js
+++ b/server/prayers.test.js
@@ -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: '' });
+});
diff --git a/src/components/PinnedPrayers.jsx b/src/components/PinnedPrayers.jsx
new file mode 100644
index 0000000..f6465f7
--- /dev/null
+++ b/src/components/PinnedPrayers.jsx
@@ -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 (
+
+
+ Praying for
+
+
+
+ {list.map((p) => {
+ const done = p.prayedOn.includes(today);
+ return (
+ -
+
+ {p.forWhom && {p.forWhom}: }
+ {p.text}
+ {p.status && {STATUSES[p.status]}}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/PinnedPrayers.test.jsx b/src/components/PinnedPrayers.test.jsx
new file mode 100644
index 0000000..7349ab2
--- /dev/null
+++ b/src/components/PinnedPrayers.test.jsx
@@ -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();
+ 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();
+});
diff --git a/src/hooks/usePrayers.js b/src/hooks/usePrayers.js
index 0b56965..9003c46 100644
--- a/src/hooks/usePrayers.js
+++ b/src/hooks/usePrayers.js
@@ -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 };
}
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
index f39d297..ea47939 100644
--- a/src/pages/HomePage.jsx
+++ b/src/pages/HomePage.jsx
@@ -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() {
);
})()}
+
diff --git a/src/pages/PrayerPage.jsx b/src/pages/PrayerPage.jsx
index e4f7837..589c4f3 100644
--- a/src/pages/PrayerPage.jsx
+++ b/src/pages/PrayerPage.jsx
@@ -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 (
@@ -41,11 +42,23 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
) : (
- {prayer.forWhom &&
For {prayer.forWhom}
}
+ {(prayer.forWhom || isPinned || prayer.status) && (
+
+ {isPinned && Pinned}
+ {prayer.status && {STATUSES[prayer.status]}}
+ {prayer.forWhom && For {prayer.forWhom}}
+
+ )}
{prayer.text}
)}
+ {prayer.status === 'partial' && prayer.answerNote && mode !== 'partial' && (
+
+
Partly answered
+
{prayer.answerNote}
+
+ )}
{prayer.answeredAt && (
Answered {new Date(prayer.answeredAt).toLocaleDateString()}{days != null ? ` · after ${days} day${days === 1 ? '' : 's'}` : ''}
@@ -61,6 +74,15 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
)}
+ {mode === 'partial' && (
+
+ )}
{!prayer.answeredAt && (
)}
{prayer.prayedOn.length > 0 &&
Prayed on {prayer.prayedOn.length} day{prayer.prayedOn.length === 1 ? '' : 's'}}
-
+
+ {!prayer.answeredAt && isPinned && (
+ <>
+
+
+ >
+ )}
+ {!prayer.answeredAt && }
+ {!prayer.answeredAt && (
+ <>
+
+
+ >
+ )}
{prayer.answeredAt
?
: }
@@ -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 (
@@ -149,7 +197,14 @@ export default function PrayerPage() {
setQuery(e.target.value)} placeholder="Search prayers…" aria-label="Search prayers" className={`${field} ml-auto w-48 py-1.5`} />
- {live(prayers).length > 0 && }
+
+
+ {live(prayers).length > 0 && (
+ <>
+
+
+ >
+ )}
{shown.length === 0 ? (
@@ -159,7 +214,7 @@ export default function PrayerPage() {
) : (
- {shown.map((p) => )}
+ {shown.map((p) => )}
)}
diff --git a/src/pages/PrayerPage.test.jsx b/src/pages/PrayerPage.test.jsx
index 929eb4c..e9ada2c 100644
--- a/src/pages/PrayerPage.test.jsx
+++ b/src/pages/PrayerPage.test.jsx
@@ -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 }) => {children}
}));
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();
@@ -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();
+});
diff --git a/src/utils/prayers.js b/src/utils/prayers.js
index 6db0b9b..62a422f 100644
--- a/src/utils/prayers.js
+++ b/src/utils/prayers.js
@@ -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);
diff --git a/src/utils/prayers.test.js b/src/utils/prayers.test.js
index 2a1dd18..34ab79f 100644
--- a/src/utils/prayers.test.js
+++ b/src/utils/prayers.test.js
@@ -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();
+});