Compare commits

...
15 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 593769b4f6 Make app icon corners transparent
Build and Push Docker Image / build-and-push (push) Successful in 4m46s
The 192 and 512 icons had opaque white corners that showed as white tips in the browser tab.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 14:01:52 -04:00
nmemmertandClaude Sonnet 5.5 843056b963 Studies: Finished & archived section with Archive/Restore
Build and Push Docker Image / build-and-push (push) Successful in 6m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:55:33 -04:00
nmemmertandClaude Sonnet 5.5 afc3206da4 Pinned prayers card: accent styling, check toggle, status badge, prayed count
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:40:44 -04:00
nmemmertandClaude Sonnet 5.5 f692cf7177 Home: align header, sticky side columns, tidy verse header, move backup line
Build and Push Docker Image / build-and-push (push) Successful in 6m25s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:36:28 -04:00
nmemmertandClaude Sonnet 5.5 00fd0cf43d Home: move prayers and recall history to sidebar, add left column for recall cards
Build and Push Docker Image / build-and-push (push) Successful in 4m47s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:32:43 -04:00
nmemmertandClaude Sonnet 5.5 1cf157be64 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>
2026-10-14 16:27:04 -04:00
nmemmertandClaude Sonnet 5.5 6d647e8d8a Merge more-translations-and-aids: KJV/ASV/LSV, dictionary, grammar, family tree, units, reference links
Build and Push Docker Image / build-and-push (push) Successful in 5m7s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:28:50 -04:00
nmemmertandClaude Sonnet 5.5 c271f49694 Grammar panel: one flowing line per term so it reads in a narrow column
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:28:07 -04:00
nmemmertandClaude Sonnet 5.5 79d9b091f9 Link Scripture references in notes and scripts to the verse (chips with a BSB preview)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:23:09 -04:00
nmemmertandClaude Sonnet 5.5 f05d38ecde Units hover notes and a family tree for people (Theographic)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:20:20 -04:00
nmemmertandClaude Sonnet 5.5 fdb7f16d8a Bible dictionary lookup in the Word study tab (IQ Bible: Easton, Hastings, Hitchcock, Schaff, Smith)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:18:01 -04:00
nmemmertandClaude Sonnet 5.5 fa9e825444 Explain grammar labels in plain English on original words and in word-study cards
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:17:02 -04:00
nmemmertandClaude Sonnet 5.5 d5cc93f9bd Add the KJV, ASV and LSV (free helloao translations; savable offline)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:14:25 -04:00
nmemmertandClaude Sonnet 5.5 6a89ee058f Merge fix-backup-nudge: account download counts as a backup
Build and Push Docker Image / build-and-push (push) Successful in 4m56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:21:55 -04:00
nmemmertandClaude Sonnet 5.5 5e80d307a7 Count "Download from my account" as a backup for the Home nudge
Only "Download backup" recorded the date, so downloading from the account left
Home saying "You haven't downloaded a backup yet". Both now record it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:21:20 -04:00
49 changed files with 1479 additions and 118 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 90 KiB

+3
View File
@@ -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,
};
}
+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', () => {
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: '' });
});
+61
View File
@@ -0,0 +1,61 @@
// A person's family: the relatives named in their record (click one to read about them), and a tree of ancestors and
// descendants a few generations out. From Theographic Bible Metadata.
import { useEffect, useState } from 'react';
import { loadPerson } from '../hooks/useVerseStudy.js';
import { relatives, walk } from '../utils/family.js';
function Branch({ node, onOpen }) {
return (
<li>
<button type="button" onClick={() => onOpen(node.id)} className="text-[var(--r-accent)] hover:underline">{node.name || node.id}</button>
{node.more && <span className="ml-1 text-[var(--r-sub)]" title="More people beyond this point">…</span>}
{node.kids.length > 0 && <ul className="ml-4 border-l border-[var(--r-line)] pl-3">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul>}
</li>
);
}
function Tree({ id, onOpen }) {
const [state, setState] = useState({ loading: true });
useEffect(() => {
let live = true;
Promise.all([walk(loadPerson, id, { direction: 'up', depth: 3, max: 30 }), walk(loadPerson, id, { direction: 'down', depth: 2, max: 40 })])
.then(([up, down]) => { if (live) setState({ up, down }); })
.catch(() => { if (live) setState({ error: true }); });
return () => { live = false; };
}, [id]);
if (state.loading) return <p className="mt-1 text-[var(--r-sub)]">Loading the family…</p>;
if (state.error) return <p className="mt-1 text-[var(--r-sub)]">The family tree couldn’t be loaded right now.</p>;
const block = (title, node, empty) => (
<div>
<p className="font-medium text-[var(--r-text)]">{title}</p>
{node.kids.length ? <ul className="mt-1 space-y-0.5">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul> : <p className="text-[var(--r-sub)]">{empty}</p>}
</div>
);
return <div className="mt-2 grid gap-3 sm:grid-cols-2">{block('Ancestors', state.up, 'None recorded.')}{block('Descendants', state.down, 'None recorded.')}</div>;
}
// `person` is the record; `renderPerson(id)` shows another person's details inside this one when a name is clicked.
export default function Family({ person, renderPerson }) {
const groups = relatives(person);
const [shown, setShown] = useState(null); // a relative opened from a name or the tree
const [treeOpen, setTreeOpen] = useState(false); // the tree's records are only fetched once it is opened
if (groups.length === 0) return null;
return (
<div className="mt-2 space-y-1.5 border-t border-[var(--r-line)] pt-2" aria-label="Family">
{groups.map((g) => (
<p key={g.label} className="flex flex-wrap items-baseline gap-x-1.5">
<span className="w-16 shrink-0 text-[11px] uppercase tracking-wide">{g.label}</span>
{g.people.map((p) => (
<button key={p.id} type="button" onClick={() => setShown(shown === p.id ? null : p.id)} aria-pressed={shown === p.id}
className={`rounded-full border px-2 py-0.5 ${shown === p.id ? 'border-[var(--r-accent)] text-[var(--r-text)]' : 'border-[var(--r-line)] text-[var(--r-accent)] hover:bg-black/5'}`}>{p.name}</button>
))}
</p>
))}
<details onToggle={(e) => setTreeOpen(e.currentTarget.open)}>
<summary className="cursor-pointer text-[var(--r-accent)]">Family tree</summary>
{treeOpen && <Tree id={person.id} onOpen={(id) => setShown(id)} />}
</details>
{shown && <div className="rounded-lg bg-[var(--r-bar)] p-2">{renderPerson(shown)}</div>}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
import Family from './FamilyTree.jsx';
const ref = (id, name) => ({ id, name, type: 'people' });
const RECORDS = {
fam_seth: { id: 'fam_seth', name: 'Seth', father: [ref('fam_adam', 'Adam')], mother: [ref('fam_eve', 'Eve')], children: [ref('fam_enos', 'Enos')], siblings: [ref('fam_abel', 'Abel')] },
fam_adam: { id: 'fam_adam', name: 'Adam', children: [ref('fam_seth', 'Seth')] },
fam_eve: { id: 'fam_eve', name: 'Eve', children: [ref('fam_seth', 'Seth')] },
fam_enos: { id: 'fam_enos', name: 'Enos', father: [ref('fam_seth', 'Seth')] },
fam_abel: { id: 'fam_abel', name: 'Abel' },
};
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async (url) => {
const id = String(url).match(/people\/(.+)\.json/)?.[1];
return RECORDS[id] ? { ok: true, json: async () => ({ person: RECORDS[id] }) } : { ok: false, json: async () => ({}) };
}));
});
test('the relatives named in the record are listed, and clicking one opens them', async () => {
const user = userEvent.setup();
render(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
const family = screen.getByLabelText('Family');
for (const name of ['Adam', 'Eve', 'Abel', 'Enos']) expect(within(family).getByRole('button', { name })).toBeInTheDocument();
await user.click(within(family).getByRole('button', { name: 'Adam' }));
expect(screen.getByText('details for fam_adam')).toBeInTheDocument();
await user.click(within(family).getByRole('button', { name: 'Adam' }));
expect(screen.queryByText('details for fam_adam')).not.toBeInTheDocument();
});
test('the family tree shows ancestors and descendants', async () => {
const user = userEvent.setup();
render(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
await user.click(screen.getByText('Family tree'));
expect(await screen.findByText('Ancestors')).toBeInTheDocument();
const tree = screen.getByText('Ancestors').closest('div').parentElement;
expect(within(tree).getAllByRole('button', { name: 'Adam' }).length).toBeGreaterThan(0);
expect(within(tree).getAllByRole('button', { name: 'Enos' }).length).toBeGreaterThan(0);
});
test('a person with no recorded family shows nothing', () => {
const { container } = render(<Family person={{ id: 'x', name: 'X' }} renderPerson={() => null} />);
expect(container).toBeEmptyDOMElement();
});
+19
View File
@@ -0,0 +1,19 @@
// What an original-language word's grammar label means, in plain English.
import { grammarNotes } from '../utils/grammar.js';
export default function GrammarPanel({ label, className = '' }) {
const notes = grammarNotes(label);
if (!label) return null;
return (
<div className={`rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-2.5 font-sans text-xs ${className}`} aria-label="Grammar">
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Grammar · {label}</p>
{notes.length > 0 && (
<ul className="mt-1.5 space-y-1.5">
{notes.map((n) => (
<li key={n.term}><span className="font-medium">{n.term}.</span> <span className="text-[var(--r-sub)]">{n.meaning}</span></li>
))}
</ul>
)}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
// 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 all = pinned(prayers);
const list = all.slice(0, 5);
const total = all.length;
if (!list.length) return null;
const today = localDateString();
return (
<section aria-label="Pinned prayers" className="rounded-xl border border-[var(--r-accent)] border-l-4 bg-[var(--r-bar)] px-4 py-3 text-sm shadow-sm">
<div className="mb-1 flex items-baseline justify-between gap-3">
<h2 className="flex items-center gap-1.5 font-semibold text-[var(--r-accent)]"><svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true"><path d="M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10Z" /></svg>Praying for <span className="text-xs font-normal text-[var(--r-sub)]">{total > list.length ? `${list.length} of ${total}` : total}</span></h2>
<button type="button" onClick={openPrayer} className="text-xs text-[var(--r-accent)] hover:underline">Open journal →</button>
</div>
<ul className="divide-y divide-[var(--r-line)]">
{list.map((p) => {
const done = p.prayedOn.includes(today);
const days = p.prayedOn.length;
return (
<li key={p.id} className="flex items-start gap-3 py-2.5 first:pt-1.5 last:pb-0.5">
<button type="button" onClick={() => togglePrayed(p.id)} aria-pressed={done} aria-label={`${done ? 'Prayed today' : 'I prayed'}: ${p.text.slice(0, 40)}`}
title={done ? 'Prayed today (tap to undo)' : 'Mark as prayed today'}
className={`mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs ${done ? 'border-[var(--r-accent)] bg-[var(--r-accent)] text-[var(--r-on)]' : 'border-[var(--r-line)] text-transparent hover:border-[var(--r-accent)] hover:text-[var(--r-accent)]'}`}>✓</button>
<div className="min-w-0 flex-1">
{(p.forWhom || p.status) && (
<p className="mb-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] font-medium uppercase tracking-wide text-[var(--r-sub)]">
{p.forWhom && <span>{p.forWhom}</span>}
{p.status && <span className="rounded-full border border-[var(--r-accent)] px-1.5 py-px text-[10px] normal-case tracking-normal text-[var(--r-accent)]">{STATUSES[p.status]}</span>}
</p>
)}
<p className="line-clamp-3 leading-snug">{p.text}</p>
{p.status === 'partial' && p.answerNote && <p className="mt-1 line-clamp-2 text-xs italic text-[var(--r-sub)]">{p.answerNote}</p>}
{days > 0 && <p className="mt-1 text-[11px] text-[var(--r-sub)]">Prayed {days} day{days === 1 ? '' : 's'}</p>}
</div>
</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();
});
+1 -1
View File
@@ -15,7 +15,7 @@ export default function RecallHistory() {
const { counts, total, recent } = recallSummary(history);
return (
<section aria-label="Recall history" className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm">
<section aria-label="Recall history" className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm">
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} className="flex w-full flex-wrap items-center justify-between gap-3 text-left">
<span><span className="font-medium">Recall history</span> <span className="text-xs text-[var(--r-sub)]">{total} recall{total === 1 ? '' : 's'} · {counts.solid} had it · {counts.partly} partly · {counts.forgot} slipped</span></span>
<span className="flex items-center gap-1" aria-label="Last eight weeks">
+63
View File
@@ -0,0 +1,63 @@
// Under a note: each Scripture reference written in it (Rom 8:28, Psalm 23…) as a chip. Click one to read the verse
// (BSB) right there, or open it in the Bible reader. The text itself is never changed.
import { useEffect, useMemo, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { fetchChapter } from '../utils/bibleText.js';
import { labelFor, uniqueReferences } from '../utils/scriptureRefs.js';
const MAX_VERSES = 12;
const CHAPTER_PREVIEW = 6;
function Preview({ r }) {
const { jumpToReaderVerse, openBibleReader } = useApp();
const [state, setState] = useState({ loading: true });
useEffect(() => {
let live = true;
setState({ loading: true });
fetchChapter('BSB', r.book, r.chapter)
.then(({ verses }) => {
const from = r.verse ?? 1;
const to = r.verse ? (r.endVerse ?? r.verse) : from + CHAPTER_PREVIEW - 1;
const picked = verses.filter((v) => v.number >= from && v.number <= to).slice(0, MAX_VERSES);
if (live) setState({ verses: picked, more: !r.verse && verses.length > picked.length });
})
.catch((err) => { if (live) setState({ error: err.message || 'Could not load the verse.' }); });
return () => { live = false; };
}, [r.book, r.chapter, r.verse, r.endVerse]);
return (
<div role="region" aria-label={`${labelFor(r)} preview`} className="mt-1.5 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 text-[13px]">
{state.loading && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
{state.error && <p className="text-xs text-[var(--r-sub)]">{state.error} You can still open it in the Bible.</p>}
{state.verses && (state.verses.length === 0
? <p className="text-xs text-[var(--r-sub)]">That verse isn’t in the BSB.</p>
: <div className="space-y-1 font-serif leading-relaxed">{state.verses.map((v) => <p key={v.number}><sup className="mr-1 font-sans text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}{state.more && <p className="text-xs text-[var(--r-sub)]">…</p>}</div>)}
<div className="mt-2 flex items-center gap-3 font-sans text-xs">
<button type="button" onClick={() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }} className="text-[var(--r-accent)] hover:underline">Read in the Bible</button>
<span className="text-[var(--r-sub)]">Berean Standard Bible</span>
</div>
</div>
);
}
export default function ScriptureRefs({ text }) {
const refs = useMemo(() => uniqueReferences(text), [text]);
const [open, setOpen] = useState(null);
if (refs.length === 0) return null;
const openRef = refs.find((r) => labelFor(r) === open);
return (
<div className="mt-1.5" aria-label="References in this note">
<p className="flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-[var(--r-sub)]">Verses mentioned:</span>
{refs.map((r) => {
const label = labelFor(r);
return (
<button key={label} type="button" aria-pressed={open === label} onClick={() => setOpen(open === label ? null : label)}
className={`rounded-full border px-2 py-0.5 ${open === label ? 'border-[var(--r-accent)] text-[var(--r-text)]' : 'border-[var(--r-line)] text-[var(--r-accent)] hover:bg-black/5'}`}>{label}</button>
);
})}
</p>
{openRef && <Preview r={openRef} />}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
import { AppContext } from '../context/AppContext.js';
import ScriptureRefs from './ScriptureRefs.jsx';
const jump = vi.fn();
const open = vi.fn();
beforeEach(() => {
jump.mockClear(); open.mockClear();
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true, status: 200,
json: async () => ({ book: { numberOfChapters: 16 }, verses: [28, 29, 30].map((n) => ({ number: n, text: `Verse ${n} text.` })) }),
})));
});
const setup = (text) => render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><ScriptureRefs text={text} /></AppContext.Provider>);
test('references in a note become chips, and a note with none shows nothing', () => {
const { container } = setup('Nothing to see here.');
expect(container).toBeEmptyDOMElement();
});
test('clicking a reference shows the verse, and "Read in the Bible" opens the reader there', async () => {
const user = userEvent.setup();
setup('God works all things together, Rom 8:28-29. See also Romans 8:28.');
expect(screen.getAllByRole('button', { name: /Romans 8/ })).toHaveLength(2); // 8:28–29 and 8:28 are different references
await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' }));
const preview = await screen.findByRole('region', { name: /Romans 8:28–29 preview/ });
expect(await within(preview).findByText('Verse 28 text.')).toBeInTheDocument();
expect(within(preview).getByText('Verse 29 text.')).toBeInTheDocument();
expect(within(preview).queryByText('Verse 30 text.')).not.toBeInTheDocument();
await user.click(within(preview).getByRole('button', { name: 'Read in the Bible' }));
expect(jump).toHaveBeenCalledWith('ROM', 8, 28);
expect(open).toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' }));
expect(screen.queryByRole('region')).not.toBeInTheDocument();
});
test('a failure to load the verse still leaves the link to the Bible', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
const user = userEvent.setup();
setup('Psalm 23:1');
await user.click(screen.getByRole('button', { name: 'Psalms 23:1' }));
const preview = await screen.findByRole('region');
expect(await within(preview).findByText(/You can still open it in the Bible/)).toBeInTheDocument();
expect(within(preview).getByRole('button', { name: 'Read in the Bible' })).toBeInTheDocument();
});
+2
View File
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useIqAvailable } from '../hooks/useIq.js';
import IqCommentary from './iq/IqCommentary.jsx';
import Family from './FamilyTree.jsx';
import {
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
} from '../hooks/useVerseStudy.js';
@@ -71,6 +72,7 @@ function PersonDetails({ id }) {
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
{!data.description?.length && <p>No description available.</p>}
<Family person={data} renderPerson={(pid) => <PersonDetails key={pid} id={pid} />} />
{data.minYear != null && (
<p className="text-[var(--r-sub,#64748b)]">
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
+2 -2
View File
@@ -60,8 +60,8 @@ function Card({ iq }) {
<section className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Daily verse and devotional">
{verse && (
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day</p>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="whitespace-nowrap text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day</p>
<span className="flex items-center gap-1.5">
<select aria-label="Verse of the day background" value={bgChoice} onChange={(e) => pickBackground(e.target.value)}
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 text-xs">
+75
View File
@@ -0,0 +1,75 @@
// A Bible dictionary lookup (Easton, Hastings, Hitchcock, Schaff, Smith: all public domain, from IQ Bible).
// Type a word, pick a dictionary; the entry named for the word comes first and the entries that only mention it
// are folded below. Scripture references in an entry open in the Reader.
import { useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { useIq } from '../../hooks/useIq.js';
import { pickEntries, preview, refTarget } from '../../utils/dictionary.js';
const field = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
function Entry({ entry, credit, open: startOpen }) {
const { jumpToReaderVerse, openBibleReader } = useApp();
const [open, setOpen] = useState(startOpen);
const p = preview(entry.definition);
const refs = [...new Set(entry.scripture_refs ?? [])].map(refTarget).filter(Boolean).slice(0, 14);
return (
<article className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3">
<h4 className="font-medium">{entry.term}</h4>
<p className="mt-1 whitespace-pre-line text-[13px] leading-relaxed">{open || !p.cut ? entry.definition : p.text}</p>
{p.cut && <button type="button" onClick={() => setOpen((o) => !o)} className="mt-1 text-xs text-[var(--r-accent)] hover:underline">{open ? 'Show less' : 'Read the whole entry'}</button>}
{refs.length > 0 && (
<p className="mt-2 flex flex-wrap gap-1.5">
{refs.map((r) => (
<button key={r.label} type="button" onClick={() => { jumpToReaderVerse(r.book, r.chapter, r.verse); openBibleReader(); }} title="Read it in the Bible"
className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-accent)] hover:bg-black/5">{r.label}</button>
))}
</p>
)}
<p className="mt-1.5 text-[10px] text-[var(--r-sub)]">{credit}</p>
</article>
);
}
export default function IqDictionary() {
const [dictId, setDictId] = useState(() => { try { return localStorage.getItem('dictionary') || 'easton'; } catch { return 'easton'; } });
const [typed, setTyped] = useState('');
const [asked, setAsked] = useState('');
const list = useIq('dictionaries');
const dictionaries = list.data?.data ?? [];
const current = dictionaries.find((d) => d.id === dictId) ?? dictionaries[0];
const result = useIq(asked && current ? `dictionaries/${current.id}` : null, { q: asked });
const { exact, others } = pickEntries(result.data?.data, asked);
const credit = current ? `${current.name}, ${current.author}, ${current.year}. ${current.license}.` : '';
const choose = (id) => { setDictId(id); try { localStorage.setItem('dictionary', id); } catch { /* storage unavailable */ } };
const ask = () => setAsked(typed.trim().slice(0, 100));
return (
<details className="mb-3 rounded-xl border border-[var(--r-line)] p-3" aria-label="Bible dictionary">
<summary className="cursor-pointer text-sm font-medium">Bible dictionary</summary>
<div className="mt-3 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<input type="text" aria-label="Look up a word" value={typed} onChange={(e) => setTyped(e.target.value)} placeholder="A name, place or word, e.g. cubit"
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); ask(); } }} className={`${field} min-w-0 flex-1`} />
<select aria-label="Dictionary" value={current?.id ?? dictId} onChange={(e) => choose(e.target.value)} className={field}>
{dictionaries.length === 0 && <option value={dictId}>{dictId}</option>}
{dictionaries.map((d) => <option key={d.id} value={d.id}>{d.name.replace(/ Bible Dictionary| Dictionary of the Bible| Bible Names Dictionary/, '')} ({d.entry_count})</option>)}
</select>
<button type="button" onClick={ask} disabled={!typed.trim()} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50">Look up</button>
</div>
{result.status === 'loading' && <p className="text-xs text-[var(--r-sub)]">Looking up…</p>}
{result.status === 'error' && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{result.error || 'The dictionary could not be reached.'}</p>}
{asked && (result.status === 'ready' || result.status === 'empty') && !exact && others.length === 0 && (
<p className="text-xs text-[var(--r-sub)]">Nothing for “{asked}” in this dictionary. Try another dictionary or the singular of the word.</p>
)}
{exact && <Entry entry={exact} credit={credit} open={false} />}
{others.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-[var(--r-sub)]">{exact ? 'Other entries that mention' : 'Entries that mention'} “{asked}” ({others.length})</summary>
<div className="mt-2 space-y-2">{others.slice(0, 8).map((e) => <Entry key={e.slug ?? e.term} entry={e} credit={credit} open={false} />)}</div>
</details>
)}
</div>
</details>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
import { AppContext } from '../../context/AppContext.js';
import IqDictionary from './IqDictionary.jsx';
const LIST = { count: 2, data: [
{ id: 'easton', name: "Easton's Bible Dictionary", author: 'Matthew George Easton', year: '1897', license: 'Public domain', entry_count: 3962 },
{ id: 'smith', name: "Smith's Bible Dictionary", author: 'William Smith', year: '1863', license: 'Public domain', entry_count: 4488 },
] };
const ANSWER = { count: 2, data: [
{ term: 'Alabaster', slug: 'alabaster', definition: 'A stone box. It mentions a talent.', scripture_refs: ['Matthew 26:7'] },
{ term: 'Talent', slug: 'talent', definition: 'The largest Hebrew weight, about 75 pounds.', scripture_refs: ['Exodus 38:25', 'Matthew 25:15'] },
] };
const jump = vi.fn();
const open = vi.fn();
const asked = [];
beforeEach(() => {
asked.length = 0; jump.mockClear(); open.mockClear(); localStorage.clear();
vi.stubGlobal('fetch', vi.fn(async (url) => {
asked.push(String(url));
const body = String(url).includes('/api/iq/dictionaries/') ? ANSWER : LIST;
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
}));
});
test('look up a word: the entry named for it comes first, its Scripture references open in the Reader', async () => {
const user = userEvent.setup();
render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><IqDictionary /></AppContext.Provider>);
await user.click(screen.getByText('Bible dictionary'));
await user.type(await screen.findByRole('textbox', { name: 'Look up a word' }), 'talent{Enter}');
const entry = (await screen.findByRole('heading', { name: 'Talent' })).closest('article');
expect(within(entry).getByText(/about 75 pounds/)).toBeInTheDocument();
expect(asked.some((u) => /dictionaries\/easton\?q=talent/.test(u))).toBe(true);
await user.click(within(entry).getByRole('button', { name: 'Matthew 25:15' }));
expect(jump).toHaveBeenCalledWith('MAT', 25, 15);
expect(open).toHaveBeenCalled();
expect(within(entry).getByText(/Easton's Bible Dictionary, Matthew George Easton, 1897\. Public domain\./)).toBeInTheDocument();
expect(screen.getByText(/Entries that mention|Other entries that mention/)).toBeInTheDocument(); // Alabaster only mentions it
});
test('the chosen dictionary is remembered and used for the next lookup', async () => {
const user = userEvent.setup();
render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><IqDictionary /></AppContext.Provider>);
await user.click(screen.getByText('Bible dictionary'));
await user.selectOptions(await screen.findByRole('combobox', { name: 'Dictionary' }), 'smith');
expect(localStorage.getItem('dictionary')).toBe('smith');
await user.type(screen.getByRole('textbox', { name: 'Look up a word' }), 'cubit{Enter}');
await screen.findByRole('heading', { name: 'Talent' });
expect(asked.some((u) => /dictionaries\/smith\?q=cubit/.test(u))).toBe(true);
});
+2 -2
View File
@@ -81,7 +81,7 @@ export default function DataSection() {
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={download} className={btn}>Download backup</button>
{authUser && (
<a href="/api/account/export" download className={`${btn} inline-block`} title="Everything saved to your account on the server, including studies from other devices">Download from my account</a>
<a href="/api/account/export" download onClick={() => markBackedUp()} className={`${btn} inline-block`} title="Everything saved to your account on the server, including studies from other devices">Download from my account</a>
)}
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
@@ -106,7 +106,7 @@ export default function DataSection() {
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.</p>
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
<div className="flex flex-wrap gap-3">
{['BSB', 'NET'].map((t) => (
{['BSB', 'NET', 'KJV', 'ASV', 'LSV'].map((t) => (
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
</button>
@@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
import { AppContext } from '../../context/AppContext.js';
import { ToastProvider } from '../Toasts.jsx';
import { lastBackupAt } from '../../utils/backup.js';
import DataSection from './DataSection.jsx';
const value = {
authUser: { email: 'a@b.test' }, readerBookmarks: {}, readerBookmarkNotes: {}, readerTextHighlights: [], readerColorLabels: {}, readerInkByPage: {},
readingPlansState: { progress: [], customPlans: [], activity: [] }, importReaderMarks: vi.fn(), importReadingPlans: vi.fn(), refreshProjectIndex: vi.fn(), prayers: [], importPrayers: vi.fn(),
};
const setup = () => {
render(<AppContext.Provider value={value}><ToastProvider><DataSection /></ToastProvider></AppContext.Provider>);
return userEvent.setup();
};
beforeEach(() => {
localStorage.clear();
URL.createObjectURL = vi.fn(() => 'blob:x');
URL.revokeObjectURL = vi.fn();
document.addEventListener('click', (e) => { if (e.target.closest?.('a[download]')) e.preventDefault(); }); // no real navigation in the test
});
test('downloading the backup file is remembered, so Home stops asking', async () => {
const user = setup();
expect(lastBackupAt()).toBeNull();
await user.click(screen.getByRole('button', { name: 'Download backup' }));
expect(lastBackupAt()).toBeGreaterThan(Date.now() - 5000);
});
test('downloading from the account is remembered too', async () => {
const user = setup();
expect(lastBackupAt()).toBeNull();
await user.click(screen.getByRole('link', { name: 'Download from my account' }));
expect(lastBackupAt()).toBeGreaterThan(Date.now() - 5000);
});
+3
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from 'react';
import SessionNotes from './SessionNotes.jsx';
import FirstDraft from './FirstDraft.jsx';
import ScriptureRefs from '../ScriptureRefs.jsx';
import { usePasteCheck } from './PastePrompt.jsx';
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
@@ -108,6 +109,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
<div className="px-4 pb-4">
<textarea ref={(el) => { areas.current[field] = el; }} aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
onFocus={() => keepVersion(field)} onBlur={() => keepVersion(field)} onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
<ScriptureRefs text={chunk[field]} />
{pasteCheck.prompt(field)}
<button type="button" onClick={() => openHistory(field)} aria-expanded={historyField === field} className="mt-1.5 mr-3 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]">History</button>
{historyField === field && (
@@ -150,6 +152,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
<ScriptureRefs text={chunk.generalNotes} />
</label>
<label className="block text-xs text-[var(--r-sub)]">
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
+10 -1
View File
@@ -2,10 +2,13 @@
// interlinear words; click an original word to add it to the chunk's word study.
import { useState } from 'react';
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
import GrammarPanel from '../GrammarPanel.jsx';
import { markUnits } from '../../utils/biblicalUnits.js';
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) {
const [openVerse, setOpenVerse] = useState(null);
const [editingNote, setEditingNote] = useState(null);
const [inspect, setInspect] = useState(null); // the original word whose grammar is showing: { key, index }
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
return (
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
@@ -20,7 +23,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
className={`mr-1.5 align-super font-sans text-[0.6em] ${words ? 'text-[var(--r-accent)] hover:underline' : 'cursor-default text-[var(--r-sub)]'} ${open ? 'font-bold' : ''}`}>
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
</button>
<span>{v.text}</span>
<span>{markUnits(v.text).map((part, i) => (part.note
? <abbr key={i} title={part.note} tabIndex={0} className="cursor-help underline decoration-dotted decoration-[var(--r-accent)] underline-offset-4">{part.text}</abbr>
: <span key={i}>{part.text}</span>))}</span>
{onVerseNote && (
<button type="button" onClick={() => setEditingNote(editingNote === `${v.chapter}:${v.number}` ? null : `${v.chapter}:${v.number}`)}
aria-label={`Note on verse ${v.number}`} title="Note on this verse"
@@ -38,6 +43,7 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
const added = w.s && addedStrongs.has(w.s.toUpperCase());
return (
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
onMouseEnter={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })} onFocus={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })}
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
@@ -45,6 +51,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
</button>
);
})}
{inspect?.key === `${v.chapter}:${v.number}` && words[inspect.index]?.p && (
<GrammarPanel label={words[inspect.index].p} className="basis-full" />
)}
</div>
)}
</div>
+5
View File
@@ -6,6 +6,8 @@ import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js
import { useIqAvailable } from '../../hooks/useIq.js';
import { normalizeStrongs } from '../../utils/iqbible.js';
import IqLexicon from '../iq/IqLexicon.jsx';
import GrammarPanel from '../GrammarPanel.jsx';
import IqDictionary from '../iq/IqDictionary.jsx';
const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
@@ -50,6 +52,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
{field('English word', 'englishGloss', 'e.g. grace')}
</div>
{word.parsing && <GrammarPanel label={word.parsing} />}
{field('Short definition', 'shortDefinition')}
{word.definitionHtml && (
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
@@ -76,8 +79,10 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy;
const iq = useIqAvailable(); // the dictionaries come from IQ Bible, so they appear only when the server has a key
return (
<div>
{iq && <IqDictionary />}
<div className="mb-3 flex flex-wrap gap-2">
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
+5 -2
View File
@@ -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 };
}
+12 -6
View File
@@ -200,17 +200,16 @@ export function useProjectLibrary({
}
};
// Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name).
const setProjectSeries = (id, series) => {
// Changes one top-level field of a stored study (from Home, without opening it) and syncs it.
const patchStoredStudy = (id, patch) => {
const loaded = loadProjectById(id);
if (!loaded) return;
const name = String(series ?? '').trim().slice(0, 80);
if ((loaded.series ?? '') === name) return;
const updated = { ...loaded, series: name, lastEdited: Date.now() };
if (Object.entries(patch).every(([k, v]) => (loaded[k] ?? (typeof v === 'boolean' ? false : '')) === v)) return;
const updated = { ...loaded, ...patch, lastEdited: Date.now() };
saveProjectToStorage(updated);
unsyncedStudies.add(id);
setProjectIndex(loadProjectIndex());
if (project?.id === id) setProject((current) => (current ? { ...current, series: name } : current));
if (project?.id === id) setProject((current) => (current ? { ...current, ...patch } : current));
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
if (result.ok) {
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
@@ -221,6 +220,12 @@ export function useProjectLibrary({
});
};
// Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name).
const setProjectSeries = (id, series) => patchStoredStudy(id, { series: String(series ?? '').trim().slice(0, 80) });
// Moves a study to (or back from) the "Finished & archived" section on Home.
const setProjectArchived = (id, archived) => patchStoredStudy(id, { archived: !!archived });
// Changes one chunk of a stored study (from Home, without opening it) and syncs it. `change` takes the chunk, returns the new one.
const updateStoredChunk = (id, chunkId, change) => {
const loaded = loadProjectById(id);
@@ -318,6 +323,7 @@ export function useProjectLibrary({
openStudyChunk,
renameProjectInStorage,
setProjectSeries,
setProjectArchived,
saveChunkRecall,
saveChunkPatch,
deleteProject,
+14
View File
@@ -105,3 +105,17 @@ test('studies only on the server are pulled down', async () => {
setup();
await waitFor(() => expect(loadProjectById('far')?.title).toBe('From elsewhere'));
});
test('archiving a study keeps it, flags it in the index, syncs it, and restoring clears the flag', async () => {
saveProjectToStorage(study('s1'));
const hook = setup();
act(() => hook.result.current.setProjectArchived('s1', true));
expect(loadProjectById('s1').archived).toBe(true);
expect(loadProjectIndex().find((e) => e.id === 's1').archived).toBe(true);
await waitFor(() => expect(sync.saveRemoteProject).toHaveBeenCalledWith(expect.objectContaining({ id: 's1', archived: true }), expect.anything()));
sync.saveRemoteProject.mockClear();
act(() => hook.result.current.setProjectArchived('s1', true)); // already archived: nothing to do
expect(sync.saveRemoteProject).not.toHaveBeenCalled();
act(() => hook.result.current.setProjectArchived('s1', false));
expect(loadProjectIndex().find((e) => e.id === 's1').archived).toBe(false);
});
+1
View File
@@ -94,6 +94,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
lexeme: w.o || entry?.lemma || '',
transliteration: w.t || entry?.translit || entry?.xlit || '',
partOfSpeech: '',
parsing: w.p || '', // the grammar label from the interlinear, explained in the word's card
shortDefinition: entry?.kjv_def || w.g || '',
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
loading: false,
+125 -81
View File
@@ -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';
@@ -49,6 +50,7 @@ export default function HomePage() {
resumeProject,
renameProjectInStorage,
setProjectSeries,
setProjectArchived,
deleteProject,
handlePlayBookAudio,
handleStopBookAudio,
@@ -67,6 +69,7 @@ export default function HomePage() {
}); // 'recent' | 'title' | 'passage'
const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them
const [showAllTags, setShowAllTags] = useState(false);
const [showArchived, setShowArchived] = useState(false); // the "Finished & archived" section is folded away until opened
const [seriesFilter, setSeriesFilter] = useState(''); // '' = all, '__none' = not in a series, or a series name
const [seriesEditingId, setSeriesEditingId] = useState(null);
const [seriesValue, setSeriesValue] = useState('');
@@ -118,25 +121,95 @@ export default function HomePage() {
}, 300);
}, [homeSearch]);
const tagList = tagCounts(projectIndex);
// Finished studies are set aside in their own section; the tag and series filters follow the active ones
const activeIndex = projectIndex.filter((e) => !e.archived);
const tagList = tagCounts(activeIndex);
const shownTags = showAllTags ? tagList : tagList.slice(0, 12);
const seriesNames = seriesList(projectIndex);
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter })
.sort((a, b) => {
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
});
const seriesNames = seriesList(activeIndex);
const byHomeSort = (a, b) => {
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
};
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter }).sort(byHomeSort);
const archivedStudies = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, archived: true }).sort(byHomeSort);
const archivedCount = projectIndex.length - activeIndex.length;
const filtering = !!homeSearch.trim() || homeTags.length > 0 || seriesFilter !== '';
const archivedOpen = showArchived || ((!!homeSearch.trim() || homeTags.length > 0) && archivedStudies.length > 0);
// With series in use (and no single series chosen) the studies are grouped under their series name
const sections = seriesFilter === '' && seriesNames.length > 0 ? groupBySeries(visible) : [{ name: '', items: visible, flat: true }];
const fieldCls = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const linkBtn = 'rounded-lg px-3 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5';
const notice = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
const renderEntry = (entry) => (
<div key={entry.id} className={homeView === 'list'
? 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3 sm:flex-row sm:items-center sm:gap-4'
: 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5'}>
{homeView === 'list'
? <BookCover summary={entry.chapterSummary} compact className="h-14 w-full sm:w-28 sm:shrink-0" />
: <BookCover summary={entry.chapterSummary} className="h-24" />}
<div className="min-w-0 flex-1">
{renamingId === entry.id ? (
<div className="flex items-center gap-2">
<input type="text" autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }
else if (e.key === 'Escape') setRenamingId(null);
}}
className={`${fieldCls} flex-1`} />
<button type="button" onClick={() => { const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setRenamingId(null)} className="text-sm text-[var(--r-sub)]">×</button>
</div>
) : (
<div className={`flex items-start gap-2 ${homeView === 'list' ? 'flex-wrap items-baseline' : 'justify-between'}`}>
<h2 className="text-base font-medium">{entry.title}</h2>
{sharedStudies?.[entry.id] !== undefined && (
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]" title="Another account invited you to edit this study">Shared{sharedStudies[entry.id] ? ` by ${sharedStudies[entry.id]}` : ''}</span>
)}
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
<button type="button" onClick={() => { setSeriesEditingId(entry.id); setSeriesValue(entry.series ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Put this study in a series">{entry.series ? '⧉ Series' : '⧉ Add to series'}</button>
</div>
)}
{seriesEditingId === entry.id && (
<div className="mt-2 flex items-center gap-2">
<input type="text" autoFocus list="series-names" aria-label="Series name" placeholder="Series name (blank to remove)" value={seriesValue} onChange={(e) => setSeriesValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); } else if (e.key === 'Escape') setSeriesEditingId(null); }}
className={`${fieldCls} flex-1`} />
<button type="button" onClick={() => { setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setSeriesEditingId(null)} aria-label="Cancel" className="text-sm text-[var(--r-sub)]">×</button>
</div>
)}
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
{entry.tags?.length > 0 && (
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
<div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
{entry.tags.slice(0, 3).map((tag) => (
<button key={tag} type="button" onClick={() => toggleTag(tag)} aria-pressed={homeTags.includes(tag)} title={`Show studies tagged “${tag}”`}
className={`rounded-full border px-2 py-0.5 text-[11px] ${homeTags.includes(tag) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{tag}</button>
))}
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
</div>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
<OfflineBadge project={studyBooks[entry.id]} offline={offline} />
</div>
<div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
{sharedStudies?.[entry.id] === undefined && (
<button type="button" onClick={() => setProjectArchived(entry.id, !entry.archived)} title={entry.archived ? 'Move back to your studies' : 'Set this study aside as finished'}
className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm text-[var(--r-sub)] hover:bg-black/5">{entry.archived ? 'Restore' : 'Archive'}</button>
)}
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">{sharedStudies?.[entry.id] !== undefined ? 'Leave' : 'Delete'}</button>
</div>
</div>
);
return (
<PageShell
title="My Studies"
active="studies"
maxWidth="max-w-6xl xl:max-w-[88rem]"
actions={(
<>
<button type="button" onClick={() => setCurrentPage('insights')} className={linkBtn} title="This week, themes, words, what slipped, follow-ups">Insights</button>
@@ -145,21 +218,15 @@ export default function HomePage() {
</>
)}
>
<main className="mx-auto grid max-w-6xl gap-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
<section className="min-w-0">
{projectIndex.length > 0 && (() => {
const age = backupAge(lastBackupAt());
return (
<p className={`mb-3 flex flex-wrap items-center gap-2 px-1 text-xs ${age.stale ? 'text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>
{age.text}.
<button type="button" onClick={openBackup} className="text-[var(--r-accent)] hover:underline">Back up now</button>
</p>
);
})()}
<DailyFive />
<main className="mx-auto grid max-w-6xl gap-y-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:max-w-[88rem] xl:grid-cols-[auto_minmax(0,1fr)_21rem]">
{/* Left column: the recall and daily-verse cards (collapses when neither has anything to show) */}
<aside className="empty:hidden lg:col-span-2 xl:sticky xl:top-16 xl:col-span-1 xl:max-h-[calc(100vh-5rem)] xl:w-80 xl:self-start xl:overflow-y-auto xl:pr-6">
<WeeklyRecall />
<RecallHistory />
<IqDailyCard />
</aside>
<section className="min-w-0 lg:pr-6">
<DailyFive />
{autoRestoredCount !== null && (
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
)}
@@ -236,7 +303,7 @@ export default function HomePage() {
</div>
)}
{visible.length === 0 && (
{visible.length === 0 && archivedStudies.length === 0 && (
<p role="status" className="mb-4 rounded-xl border border-dashed border-[var(--r-line)] px-4 py-6 text-center text-sm text-[var(--r-sub)]">
No studies match{homeTags.length ? ` ${homeTags.map((t) => `“${t}”`).join(' + ')}` : ''}{homeSearch.trim() ? ` “${homeSearch.trim()}”` : ''}.{' '}
<button type="button" onClick={() => { setHomeTags([]); setHomeSearch(''); }} className="text-[var(--r-accent)] hover:underline">Clear filters</button>
@@ -277,75 +344,42 @@ export default function HomePage() {
</h3>
)}
<div className={homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}>
{section.items.map((entry) => (
<div key={entry.id} className={homeView === 'list'
? 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3 sm:flex-row sm:items-center sm:gap-4'
: 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5'}>
{homeView === 'list'
? <BookCover summary={entry.chapterSummary} compact className="h-14 w-full sm:w-28 sm:shrink-0" />
: <BookCover summary={entry.chapterSummary} className="h-24" />}
<div className="min-w-0 flex-1">
{renamingId === entry.id ? (
<div className="flex items-center gap-2">
<input type="text" autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }
else if (e.key === 'Escape') setRenamingId(null);
}}
className={`${fieldCls} flex-1`} />
<button type="button" onClick={() => { const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setRenamingId(null)} className="text-sm text-[var(--r-sub)]">×</button>
</div>
) : (
<div className={`flex items-start gap-2 ${homeView === 'list' ? 'flex-wrap items-baseline' : 'justify-between'}`}>
<h2 className="text-base font-medium">{entry.title}</h2>
{sharedStudies?.[entry.id] !== undefined && (
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]" title="Another account invited you to edit this study">Shared{sharedStudies[entry.id] ? ` by ${sharedStudies[entry.id]}` : ''}</span>
)}
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
<button type="button" onClick={() => { setSeriesEditingId(entry.id); setSeriesValue(entry.series ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Put this study in a series">{entry.series ? '⧉ Series' : '⧉ Add to series'}</button>
</div>
)}
{seriesEditingId === entry.id && (
<div className="mt-2 flex items-center gap-2">
<input type="text" autoFocus list="series-names" aria-label="Series name" placeholder="Series name (blank to remove)" value={seriesValue} onChange={(e) => setSeriesValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); } else if (e.key === 'Escape') setSeriesEditingId(null); }}
className={`${fieldCls} flex-1`} />
<button type="button" onClick={() => { setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setSeriesEditingId(null)} aria-label="Cancel" className="text-sm text-[var(--r-sub)]">×</button>
</div>
)}
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
{entry.tags?.length > 0 && (
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
<div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
{entry.tags.slice(0, 3).map((tag) => (
<button key={tag} type="button" onClick={() => toggleTag(tag)} aria-pressed={homeTags.includes(tag)} title={`Show studies tagged “${tag}”`}
className={`rounded-full border px-2 py-0.5 text-[11px] ${homeTags.includes(tag) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{tag}</button>
))}
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
</div>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
<OfflineBadge project={studyBooks[entry.id]} offline={offline} />
</div>
<div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">{sharedStudies?.[entry.id] !== undefined ? 'Leave' : 'Delete'}</button>
</div>
</div>
))}
{section.items.map(renderEntry)}
</div>
</div>
))}
{visible.length === 0 && archivedStudies.length > 0 && !filtering && (
<p role="status" className="mb-4 rounded-xl border border-dashed border-[var(--r-line)] px-4 py-6 text-center text-sm text-[var(--r-sub)]">Everything is finished or archived. Open the section below to bring a study back.</p>
)}
{archivedCount > 0 && (
<div className="mt-6 border-t border-[var(--r-line)] pt-4">
<button type="button" onClick={() => setShowArchived((o) => !o)} aria-expanded={archivedOpen} aria-controls="archived-studies"
className="flex w-full items-center gap-2 text-left text-sm font-medium">
<span aria-hidden="true" className="text-xs text-[var(--r-sub)]">{archivedOpen ? '▾' : '▸'}</span>
Finished &amp; archived <span className="text-xs font-normal text-[var(--r-sub)]">{filtering && archivedStudies.length !== archivedCount ? `${archivedStudies.length} of ${archivedCount}` : archivedCount}</span>
</button>
{archivedOpen && (
<div id="archived-studies" className={`mt-3 ${homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}`}>
{archivedStudies.length === 0
? <p className="text-sm text-[var(--r-sub)]">No archived studies match.</p>
: archivedStudies.map(renderEntry)}
</div>
)}
</div>
)}
</>
)}
</section>
<aside className="space-y-4">
<aside className="space-y-4 lg:sticky lg:top-16 lg:max-h-[calc(100vh-5rem)] lg:self-start lg:overflow-y-auto">
<TodayCard planProgress={planProgress} readingActivity={readingActivity} openReadingPlans={openReadingPlans}
openPlanReading={openPlanReading} studyReadingPlanDay={studyReadingPlanDay} studyBuildBusy={studyBuildBusy} />
<PinnedPrayers />
<RecallHistory />
{/* BSB audio player — always shown, so it's easy to find */}
<AudioPlayer
title="Listen to BSB Audio"
@@ -378,6 +412,16 @@ export default function HomePage() {
</>
)}
/>
{projectIndex.length > 0 && (() => {
const age = backupAge(lastBackupAt());
return (
<p className={`flex flex-wrap items-center gap-2 px-1 text-xs ${age.stale ? 'text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>
{age.text}.
<button type="button" onClick={openBackup} className="text-[var(--r-accent)] hover:underline">Back up now</button>
</p>
);
})()}
</aside>
</main>
</PageShell>
+68 -13
View File
@@ -1,10 +1,10 @@
// The prayer journal: what you are praying about (and for whom), the verses tied to each request, the days you
// prayed, and the answers when they come.
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
import { useToast } from '../components/Toasts.jsx';
import { filterPrayers, daysToAnswer, labelFor, live, parseVerses, prayersToMarkdown } from '../utils/prayers.js';
import { STATUSES, filterPrayers, daysToAnswer, labelFor, live, parsePrayerImport, parseVerses, pinned as pinnedList, prayersToJson, prayersToMarkdown } from '../utils/prayers.js';
import { localDateString } from '../utils/readingPlans.js';
const field = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
@@ -23,11 +23,12 @@ function VerseChips({ verses, onOpen }) {
);
}
function PrayerCard({ prayer, today, actions, onOpenVerse }) {
const [mode, setMode] = useState(null); // null | 'edit' | 'answer'
function PrayerCard({ prayer, today, actions, onOpenVerse, pinPos }) {
const [mode, setMode] = useState(null); // null | 'edit' | 'answer' | 'partial'
const [text, setText] = useState('');
const [note, setNote] = useState('');
const prayedToday = prayer.prayedOn.includes(today);
const isPinned = !!pinPos;
const days = daysToAnswer(prayer);
return (
<li className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4">
@@ -41,11 +42,23 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
</div>
) : (
<div>
{prayer.forWhom && <p className="text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">For {prayer.forWhom}</p>}
{(prayer.forWhom || isPinned || prayer.status) && (
<p className="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">
{isPinned && <span className="text-[var(--r-accent)]">Pinned</span>}
{prayer.status && <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 normal-case tracking-normal">{STATUSES[prayer.status]}</span>}
{prayer.forWhom && <span>For {prayer.forWhom}</span>}
</p>
)}
<p className="whitespace-pre-wrap text-[15px] leading-relaxed">{prayer.text}</p>
<VerseChips verses={prayer.verses} onOpen={onOpenVerse} />
</div>
)}
{prayer.status === 'partial' && prayer.answerNote && mode !== 'partial' && (
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note">
<p className="text-xs font-medium text-[var(--r-accent)]">Partly answered</p>
<p className="mt-0.5 whitespace-pre-wrap">{prayer.answerNote}</p>
</div>
)}
{prayer.answeredAt && (
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note">
<p className="text-xs font-medium text-[var(--r-accent)]">Answered {new Date(prayer.answeredAt).toLocaleDateString()}{days != null ? ` · after ${days} day${days === 1 ? '' : 's'}` : ''}</p>
@@ -61,6 +74,15 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
</div>
</div>
)}
{mode === 'partial' && (
<div className="mt-3">
<textarea aria-label="What has been answered so far?" autoFocus value={note} onChange={(e) => setNote(e.target.value)} rows={2} maxLength={1000} placeholder="What has been answered so far? (optional)" className={`${field} w-full`} />
<div className="mt-2 flex gap-3 text-xs">
<button type="button" onClick={() => { actions.status(prayer.id, 'partial', note); setMode(null); }} className="font-medium text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setMode(null)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
{!prayer.answeredAt && (
<button type="button" onClick={() => actions.toggle(prayer.id)} aria-pressed={prayedToday} className={`${small} ${prayedToday ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>
@@ -68,7 +90,20 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
</button>
)}
{prayer.prayedOn.length > 0 && <span className="text-xs text-[var(--r-sub)]">Prayed on {prayer.prayedOn.length} day{prayer.prayedOn.length === 1 ? '' : 's'}</span>}
<span className="ml-auto flex gap-2">
<span className="ml-auto flex flex-wrap gap-2">
{!prayer.answeredAt && isPinned && (
<>
<button type="button" onClick={() => actions.move(prayer.id, -1)} disabled={pinPos.index === 0} aria-label="Move up" title="Move up" className={`${small} disabled:opacity-40`}>↑</button>
<button type="button" onClick={() => actions.move(prayer.id, 1)} disabled={pinPos.index === pinPos.count - 1} aria-label="Move down" title="Move down" className={`${small} disabled:opacity-40`}>↓</button>
</>
)}
{!prayer.answeredAt && <button type="button" onClick={() => actions.pin(prayer.id, !isPinned)} aria-pressed={isPinned} className={`${small} ${isPinned ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>{isPinned ? 'Unpin' : 'Pin'}</button>}
{!prayer.answeredAt && (
<>
<button type="button" onClick={() => actions.status(prayer.id, prayer.status === 'waiting' ? '' : 'waiting')} aria-pressed={prayer.status === 'waiting'} className={`${small} ${prayer.status === 'waiting' ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>Waiting</button>
<button type="button" onClick={() => { setNote(prayer.status === 'partial' ? prayer.answerNote : ''); setMode('partial'); }} aria-pressed={prayer.status === 'partial'} className={`${small} ${prayer.status === 'partial' ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>Partly answered</button>
</>
)}
{prayer.answeredAt
? <button type="button" onClick={() => actions.reopen(prayer.id)} className={small}>Pray again</button>
: <button type="button" onClick={() => { setNote(''); setMode('answer'); }} className={small}>Answered</button>}
@@ -81,12 +116,13 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
}
export default function PrayerPage() {
const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, prayerDraft, clearPrayerDraft, jumpToReaderVerse, openBibleReader } = useApp();
const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, pinPrayer, movePrayer, setPrayerStatus, importPrayers, prayerDraft, clearPrayerDraft, jumpToReaderVerse, openBibleReader } = useApp();
const { toast } = useToast();
const [tab, setTab] = useState('open');
const [query, setQuery] = useState('');
const [form, setForm] = useState({ text: '', forWhom: '', verses: '' });
const today = localDateString();
const fileRef = useRef(null);
// "Pray about this verse" from the Reader arrives with the verse filled in
useEffect(() => {
@@ -105,19 +141,31 @@ export default function PrayerPage() {
setTab('open');
};
const actions = {
edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer,
edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer, pin: pinPrayer, move: movePrayer, status: setPrayerStatus,
answer: (id, note) => { answerPrayer(id, note); toast('Marked answered. Thank God for it.'); },
remove: (id) => { const undo = deletePrayer(id); toast('Prayer deleted', { action: { label: 'Undo', onClick: undo } }); },
};
const openVerse = (book, chapter, verse) => { jumpToReaderVerse(book, chapter, verse); openBibleReader(); };
const download = () => {
const url = URL.createObjectURL(new Blob([prayersToMarkdown(prayers)], { type: 'text/markdown;charset=utf-8' }));
const pinOrder = useMemo(() => pinnedList(prayers).map((p) => p.id), [prayers]);
const download = (json = false) => {
const url = URL.createObjectURL(new Blob([json ? prayersToJson(prayers) : prayersToMarkdown(prayers)], { type: json ? 'application/json' : 'text/markdown;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = 'prayer-journal.md';
a.href = url; a.download = json ? 'prayer-journal.json' : 'prayer-journal.md';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const onImportFile = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
let result;
try { result = parsePrayerImport(await file.text(), prayers); } catch { result = { prayers: [], skipped: 0, error: 'Couldn’t read that file.' }; }
if (result.error) { toast(result.error); return; }
if (result.prayers.length) importPrayers(result.prayers);
toast(`Imported ${result.prayers.length} prayer${result.prayers.length === 1 ? '' : 's'}${result.skipped ? ` (${result.skipped} already here or skipped)` : ''}`);
};
return (
<PageShell title="Prayer journal" active="prayer" maxWidth="max-w-3xl">
<main className="mx-auto max-w-3xl space-y-5 px-4 py-6">
@@ -149,7 +197,14 @@ export default function PrayerPage() {
<button type="button" role="tab" aria-selected={tab === 'all'} onClick={() => setTab('all')} className={tabBtn(tab === 'all')}>All</button>
</div>
<input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search prayers…" aria-label="Search prayers" className={`${field} ml-auto w-48 py-1.5`} />
{live(prayers).length > 0 && <button type="button" onClick={download} className={small}>Download</button>}
<button type="button" onClick={() => fileRef.current?.click()} className={small}>Import</button>
<input ref={fileRef} type="file" accept=".json,.md,.txt,text/markdown,application/json,text/plain" onChange={onImportFile} className="hidden" aria-label="Import prayers file" />
{live(prayers).length > 0 && (
<>
<button type="button" onClick={() => download(false)} className={small}>Download</button>
<button type="button" onClick={() => download(true)} className={small} title="Keeps every detail, including the days you prayed">Download JSON</button>
</>
)}
</div>
{shown.length === 0 ? (
@@ -159,7 +214,7 @@ export default function PrayerPage() {
</p>
) : (
<ul className="space-y-3" aria-label="Prayers">
{shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} />)}
{shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} pinPos={pinOrder.includes(p.id) ? { index: pinOrder.indexOf(p.id), count: pinOrder.length } : null} />)}
</ul>
)}
</main>
+32 -2
View File
@@ -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();
});
+2
View File
@@ -2,6 +2,7 @@
// write the final script beside your study notes. Claude prompts and the
// pronunciation guide live here because this is where they're needed.
import { useState } from 'react';
import ScriptureRefs from '../components/ScriptureRefs.jsx';
import PresentMode from '../components/PresentMode.jsx';
import DictateButton, { appendSpoken } from '../components/DictateButton.jsx';
import { useIqAvailable } from '../hooks/useIq.js';
@@ -174,6 +175,7 @@ export default function ScriptPage() {
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
placeholder="Write or paste the final script here. “Build outline from notes” starts you from your study notes."
className="min-h-[24rem] flex-1 resize-y rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] px-5 py-4 font-serif text-[16px] leading-8 text-[var(--r-text)] placeholder:font-sans placeholder:text-sm placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
<ScriptureRefs text={script} />
</div>
)}
</main>
+5
View File
@@ -8,6 +8,9 @@ export const READER_TRANSLATIONS = [
notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' },
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net',
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' },
{ id: 'KJV', name: 'King James Version', helloao: 'eng_kjv', notice: 'The King James Version is in the public domain.' },
{ id: 'ASV', name: 'American Standard Version (1901)', helloao: 'eng_asv', notice: 'The American Standard Version (1901) is in the public domain.' },
{ id: 'LSV', name: 'Literal Standard Version', helloao: 'eng_lsv', notice: 'Literal Standard Version © 2020 Covenant Press and the Covenant Christian Coalition. Free to read and share. lsvbible.com' },
{ id: 'NLT', name: 'New Living Translation', licensed: true,
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
@@ -16,6 +19,8 @@ export const READER_TRANSLATIONS = [
notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' },
];
// The address code helloao uses for a translation (NET is 'eng_net', the KJV 'eng_kjv'; BSB is its own)
export const helloaoCode = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.helloao ?? id ?? 'BSB';
export const translationName = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.name ?? id;
export const isLicensed = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.licensed === true;
+1 -1
View File
@@ -485,7 +485,7 @@ export function parseBibleChapter(data) {
.filter((item) => item?.type === 'verse')
.map((item) => ({
number: item.number,
text: renderVerseContent(item.content).replace(/\s+/g, ' ').trim(),
text: renderVerseContent(item.content).replace(/¶\s*/g, '').replace(/\s+/g, ' ').trim(),
}));
}
+55
View File
@@ -0,0 +1,55 @@
// Ancient weights, measures and money, with a modern guide. Hover a unit in the passage to see it. The sizes are the
// usual round figures; the real ones varied by place and period, so every note says "about".
const UNITS = [
// length
{ words: ['cubit', 'cubits'], note: 'A cubit: about 18 inches (45 cm), the length from elbow to fingertip.' },
{ words: ['span', 'spans'], note: 'A span: about 9 inches (23 cm), a hand stretched wide. Half a cubit.' },
{ words: ['handbreadth', 'handbreadths', 'handsbreadth'], note: 'A handbreadth: about 3 inches (7.5 cm), the width of four fingers.' },
{ words: ['fathom', 'fathoms'], note: 'A fathom: about 6 feet (1.8 m), the span of outstretched arms.' },
{ words: ['furlong', 'furlongs', 'stadia', 'stadion'], note: 'A stadion (furlong): about 607 feet (185 m), an eighth of a Roman mile.' },
{ words: ['mile', 'miles'], note: 'A Roman mile: about 4,860 feet (1.48 km), a little shorter than a modern mile.' },
// weight
{ words: ['talent', 'talents'], note: 'A talent: about 75 pounds (34 kg) of silver or gold. Worth around 6,000 days of a labourer’s wages.' },
{ words: ['mina', 'minas', 'pound', 'pounds'], note: 'A mina: about 1¼ pounds (0.57 kg). A hundred days’ wages. (Older versions call it a "pound".)', only: ['mina', 'minas'] },
{ words: ['shekel', 'shekels'], note: 'A shekel: about 0.4 ounce (11 g). The usual weight of silver for payments.' },
{ words: ['beka'], note: 'A beka: about 0.2 ounce (5.7 g). Half a shekel.' },
{ words: ['gerah', 'gerahs'], note: 'A gerah: about 0.02 ounce (0.6 g). A twentieth of a shekel.' },
// money
{ words: ['denarius', 'denarii'], note: 'A denarius: a silver Roman coin, about a day’s wage for a labourer.' },
{ words: ['drachma', 'drachmas'], note: 'A drachma: a Greek silver coin, about a day’s wage.' },
{ words: ['penny', 'pennies', 'pence'], note: 'In older versions, a "penny" is usually a denarius, about a day’s wage.' },
{ words: ['farthing', 'farthings'], note: 'A farthing: a small Roman copper coin, a fraction of a day’s wage.' },
{ words: ['lepton', 'mite', 'mites'], note: 'A lepton ("mite"): the smallest Greek coin, a fraction of a day’s wage.' },
{ words: ['quadrans'], note: 'A quadrans: a small Roman copper coin worth a fraction of a day’s wage.' },
// volume
{ words: ['ephah', 'ephahs'], note: 'An ephah: about 22 liters (⅗ bushel) of grain. Ten omers.' },
{ words: ['omer', 'omers'], note: 'An omer: about 2.2 liters (2 quarts) of grain. A tenth of an ephah.' },
{ words: ['homer', 'homers'], note: 'A homer: about 220 liters (6 bushels), a donkey load. Ten ephahs.' },
{ words: ['seah', 'seahs'], note: 'A seah: about 7 liters (7½ quarts) of grain. A third of an ephah.' },
{ words: ['bath', 'baths'], note: 'A bath: about 22 liters (6 gallons) of liquid. The same size as an ephah.' },
{ words: ['hin'], note: 'A hin: about 3.7 liters (4 quarts) of liquid.' },
{ words: ['cor', 'cors', 'kor'], note: 'A cor: about 220 liters (6 bushels). The same as a homer.' },
];
// word -> note, for the words that are safe to mark in running text ("pound" is left out: it is a common word and only the mina is meant in a few verses)
const INDEX = new Map();
for (const u of UNITS) for (const w of u.words) {
if (u.only && !u.only.includes(w)) continue; // "pounds" and "penny" are common words with other meanings; "bath" too
INDEX.set(w, u.note);
}
const RE = new RegExp(`\\b(${[...INDEX.keys()].sort((a, b) => b.length - a.length).join('|')})\\b`, 'gi');
export const unitNote = (word) => INDEX.get(String(word).toLowerCase()) ?? null;
// Splits a verse into plain pieces and unit words: [{ text, note? }]. Text that has no unit is a single piece.
export function markUnits(text) {
const parts = [];
let at = 0;
for (const m of String(text ?? '').matchAll(RE)) {
if (m.index > at) parts.push({ text: text.slice(at, m.index) });
parts.push({ text: m[0], note: INDEX.get(m[0].toLowerCase()) });
at = m.index + m[0].length;
}
if (at < text.length) parts.push({ text: text.slice(at) });
return parts.length ? parts : [{ text: text ?? '' }];
}
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from 'vitest';
import { markUnits, unitNote } from './biblicalUnits.js';
const noted = (text) => markUnits(text).filter((p) => p.note).map((p) => p.text);
test('units in a verse are found with their meaning, in any form', () => {
expect(noted('The ark was two and a half cubits long and a handbreadth thick.')).toEqual(['cubits', 'handbreadth']);
expect(noted('He gave him five talents, and she lost one drachma; it cost a denarius.')).toEqual(['talents', 'drachma', 'denarius']);
expect(noted('An ephah of flour, an omer of manna, and a hin of oil.')).toEqual(['ephah', 'omer', 'hin']);
expect(noted('about twenty-five or thirty stadia')).toEqual(['stadia']);
expect(unitNote('Talent')).toMatch(/75 pounds/);
});
test('the pieces rejoin to the original text, and a verse with no units is one piece', () => {
const text = 'five talents of silver (a talent is heavy)';
expect(markUnits(text).map((p) => p.text).join('')).toBe(text);
expect(markUnits('For God so loved the world')).toEqual([{ text: 'For God so loved the world' }]);
expect(markUnits('')).toEqual([{ text: '' }]);
});
test('ordinary words are not mistaken for units', () => {
expect(noted('He took a pound of flesh; a span of the river; the mile stone')).toEqual(['span', 'mile']);
expect(noted('Bathsheba and Cornelius, the stadium crowd, a corner and a hinge')).toEqual([]);
});
test('a mina is a mina, and the older word pound is left alone', () => {
expect(noted('ten minas and a mina')).toEqual(['minas', 'mina']);
expect(unitNote('pound')).toBeNull();
});
+30
View File
@@ -0,0 +1,30 @@
// Bible dictionary lookups (IQ Bible: Easton, Hastings, Hitchcock, Schaff, Smith). The route is
// /dictionaries/{id}?q=term and answers { count, data: [{ term, slug, definition, scripture_refs?, citations? }] },
// matching the word anywhere in an entry, so the entry actually named for the word is put first.
import { parsePassageRef } from './passageRef.js';
const norm = (s) => String(s ?? '').trim().toLowerCase().replace(/[^a-z0-9 ]/g, '');
// rows -> { exact: the entry whose heading is the word (or null), others: the rest }
export function pickEntries(rows, q) {
const list = Array.isArray(rows) ? rows.filter((r) => r && typeof r.definition === 'string') : [];
const want = norm(q);
const exact = list.find((r) => norm(r.term) === want) ?? null;
return { exact, others: list.filter((r) => r !== exact) };
}
// "Exodus 4:27-30", "Psalms 106:19", "Leviticus 8" -> { book, chapter, verse } for the Reader, or null
export function refTarget(text) {
const cleaned = String(text ?? '').replace(/\s*[-–,].*$/, '').trim();
const hit = parsePassageRef(cleaned)[0];
return hit ? { book: hit.book, chapter: hit.chapter, verse: hit.verse ?? 1, label: String(text).trim() } : null;
}
// A long definition is cut at a sentence end near `max` characters for the preview; the full text stays one click away.
export function preview(text, max = 420) {
const t = String(text ?? '').trim();
if (t.length <= max) return { text: t, cut: false };
const upTo = t.slice(0, max);
const end = Math.max(upTo.lastIndexOf('. '), upTo.lastIndexOf('; '));
return { text: `${end > max * 0.5 ? upTo.slice(0, end + 1) : upTo.replace(/\s+\S*$/, '')}…`, cut: true };
}
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from 'vitest';
import { pickEntries, preview, refTarget } from './dictionary.js';
const rows = [
{ term: 'Alabaster', slug: 'alabaster', definition: 'A stone… a denarius…' },
{ term: 'Denarius', slug: 'denarius', definition: 'A Roman silver coin.', scripture_refs: ['Matthew 20:2'] },
{ term: 'Farthing', slug: 'farthing', definition: 'mentions the denarius' },
];
test('the entry named for the word comes first, the entries that only mention it follow', () => {
const { exact, others } = pickEntries(rows, ' denarius ');
expect(exact.term).toBe('Denarius');
expect(others.map((o) => o.term)).toEqual(['Alabaster', 'Farthing']);
expect(pickEntries(rows, 'penny').exact).toBeNull();
expect(pickEntries(rows, 'penny').others).toHaveLength(3);
expect(pickEntries(null, 'x')).toEqual({ exact: null, others: [] });
expect(pickEntries([{ term: 'Bad' }], 'bad').exact).toBeNull(); // an entry without a definition is ignored
});
test('Scripture references in an entry are turned into places in the Reader', () => {
expect(refTarget('Exodus 4:27-30')).toMatchObject({ book: 'EXO', chapter: 4, verse: 27, label: 'Exodus 4:27-30' });
expect(refTarget('Psalms 106:19')).toMatchObject({ book: 'PSA', chapter: 106, verse: 19 });
expect(refTarget('Leviticus 8')).toMatchObject({ book: 'LEV', chapter: 8, verse: 1 });
expect(refTarget('1 Chronicles 2:10')).toMatchObject({ book: '1CH', chapter: 2, verse: 10 });
expect(refTarget('nonsense')).toBeNull();
});
test('a long definition is previewed up to a sentence end', () => {
const long = `${'First sentence is here. '.repeat(30)}`;
const p = preview(long, 100);
expect(p.cut).toBe(true);
expect(p.text.endsWith('…')).toBe(true);
expect(p.text.length).toBeLessThan(110);
expect(preview('Short.', 100)).toEqual({ text: 'Short.', cut: false });
});
+35
View File
@@ -0,0 +1,35 @@
// Family connections from the Theographic person records (father, mother, siblings, partners, children).
const GROUPS = [['father', 'Father'], ['mother', 'Mother'], ['partners', 'Partner'], ['siblings', 'Siblings'], ['children', 'Children']];
const asList = (v) => (Array.isArray(v) ? v : v ? [v] : []).filter((p) => p && p.id && p.name);
// -> [{ label: 'Children', people: [{ id, name }] }] for the relations this person has
export function relatives(person) {
return GROUPS.map(([key, label]) => ({ label: key === 'partners' && asList(person?.partners).length > 1 ? 'Partners' : label, people: asList(person?.[key]).map(({ id, name }) => ({ id, name })) }))
.filter((g) => g.people.length > 0);
}
// Walks up (parents) or down (children) from one person, `depth` generations, never visiting anyone twice and
// stopping after `max` people so a long line like Adam's doesn't fetch the whole Bible.
// load(id) -> person record or null. -> { id, name, more: bool, kids: [node…] } (kids are the parents when going up)
export async function walk(load, id, { direction = 'down', depth = 2, max = 40 } = {}) {
const seen = new Set([id]);
let budget = max;
const links = (p) => (direction === 'down' ? asList(p?.children) : [...asList(p?.father), ...asList(p?.mother)]);
async function visit(personId, name, level) {
const person = await load(personId);
const next = links(person).filter((l) => !seen.has(l.id));
const node = { id: personId, name: person?.name ?? name, more: false, kids: [] };
if (level >= depth) { node.more = next.length > 0; return node; }
const taking = next.filter(() => budget > 0 && (budget -= 1) >= 0);
taking.forEach((l) => seen.add(l.id));
node.more = taking.length < next.length;
node.kids = await Promise.all(taking.map((l) => visit(l.id, l.name, level + 1)));
return node;
}
const root = await load(id);
return visit(id, root?.name ?? '', 0);
}
// Counts the people below a node (not counting it)
export const countKids = (node) => node.kids.reduce((n, k) => n + 1 + countKids(k), 0);
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from 'vitest';
import { countKids, relatives, walk } from './family.js';
const p = (id, name, extra = {}) => ({ id, name, ...extra });
const ref = (id, name) => ({ id, name, type: 'people' });
const people = {
adam: p('adam', 'Adam', { partners: [ref('eve', 'Eve')], children: [ref('cain', 'Cain'), ref('abel', 'Abel'), ref('seth', 'Seth')] }),
eve: p('eve', 'Eve', { partners: [ref('adam', 'Adam')], children: [ref('cain', 'Cain'), ref('abel', 'Abel'), ref('seth', 'Seth')] }),
cain: p('cain', 'Cain', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')], children: [ref('enoch', 'Enoch')] }),
abel: p('abel', 'Abel', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')] }),
seth: p('seth', 'Seth', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')], children: [ref('enos', 'Enos')], siblings: [ref('cain', 'Cain'), ref('abel', 'Abel')] }),
enoch: p('enoch', 'Enoch', { father: [ref('cain', 'Cain')], children: [ref('irad', 'Irad')] }),
enos: p('enos', 'Enos', { father: [ref('seth', 'Seth')] }),
};
const load = async (id) => people[id] ?? null;
const names = (n) => ({ [n.name]: n.kids.map(names) });
test('relations are grouped and named, and empty ones are left out', () => {
expect(relatives(people.seth).map((g) => [g.label, g.people.map((x) => x.name)])).toEqual([
['Father', ['Adam']], ['Mother', ['Eve']], ['Siblings', ['Cain', 'Abel']], ['Children', ['Enos']],
]);
expect(relatives(people.adam).map((g) => g.label)).toEqual(['Partner', 'Children']);
expect(relatives({ id: 'x', name: 'X', partners: [ref('a', 'A'), ref('b', 'B')] })[0].label).toBe('Partners');
expect(relatives(null)).toEqual([]);
});
test('descendants are walked a few generations down', async () => {
const tree = await walk(load, 'adam', { direction: 'down', depth: 2 });
expect(names(tree)).toEqual({ Adam: [{ Cain: [{ Enoch: [] }] }, { Abel: [] }, { Seth: [{ Enos: [] }] }] });
expect(countKids(tree)).toBe(5);
const shallow = await walk(load, 'adam', { direction: 'down', depth: 1 });
expect(shallow.kids.find((k) => k.name === 'Cain').more).toBe(true); // Cain has children not shown
});
test('ancestors go up through both parents, without visiting anyone twice', async () => {
const up = await walk(load, 'enoch', { direction: 'up', depth: 3 });
expect(names(up)).toEqual({ Enoch: [{ Cain: [{ Adam: [] }, { Eve: [] }] }] });
});
test('a long line stops at the limit and says there is more', async () => {
const tree = await walk(load, 'adam', { direction: 'down', depth: 3, max: 2 });
expect(countKids(tree)).toBeLessThanOrEqual(2 + 1); // two taken at the top, each may take nothing more
expect(tree.more).toBe(true);
});
test('someone missing from the data still appears by name', async () => {
const tree = await walk(load, 'adam', { direction: 'down', depth: 3 });
const enoch = tree.kids[0].kids[0];
expect(enoch.kids[0].name).toBe('Irad'); // Enoch's child Irad has no record but is named by the link
});
+111
View File
@@ -0,0 +1,111 @@
// Plain-English meanings for the grammar labels on the original-language words ("Verb - Aorist Indicative Active -
// 3rd Person Singular"). The labels come with the interlinear text; this only explains them, so a reader who never
// studied Greek or Hebrew can see what "aorist" or "Hiphil" tells them. Meanings are the usual textbook ones and
// are a guide, not a rule: the context decides.
// Hebrew verbs name a "stem" (how the action is done); when one is present, Perfect, Imperfect and the rest
// mean the Hebrew thing, not the Greek one.
const HEBREW_STEMS = [
['qal', 'The basic, simple action ("he kept").'],
['niphal', 'Often passive or reflexive ("it was kept", "he kept himself").'],
['piel', 'Intensive or repeated action ("he kept carefully").'],
['pual', 'The passive of the Piel ("it was kept carefully").'],
['hithpael', 'Reflexive or repeated action ("he kept himself busy").'],
['hiphil', 'Causing the action ("he made someone keep").'],
['hophal', 'The passive of the Hiphil ("he was made to keep").'],
];
const HEBREW_FORMS = [
['perfect', 'Action seen as complete, often past ("he kept").'],
['imperfect', 'Action seen as incomplete: ongoing, repeated or still to come ("he keeps", "he will keep").'],
['imperative', 'A command ("keep!").'],
['infinitive construct', 'The verb as a noun, linked to what follows ("keeping of…").'],
['infinitive absolute', 'The verb as a noun on its own, often to stress the action ("keeping, keep").'],
['participle', 'A verb used like an adjective or noun ("keeping", "the one who keeps").'],
];
const GREEK_TENSES = [
['present', 'Ongoing or repeated action ("is doing").'],
['imperfect', 'Past action that was ongoing or repeated ("was doing").'],
['future', 'Action that will happen ("will do").'],
['aorist', 'Action seen as a whole, with no focus on how long it took. Often just "did".'],
['perfect', 'A finished action whose results still stand ("has done").'],
['pluperfect', 'A finished action whose results stood at some past time ("had done").'],
];
const MOODS = [
['indicative', 'States something as a fact or asks a question.'],
['subjunctive', 'Possibility, purpose or doubt ("may", "might", "let us").'],
['imperative', 'A command or request.'],
['optative', 'A wish or a remote possibility ("may it be").'],
['infinitive', 'The verb as a noun ("to do").'],
['participle', 'A verb used like an adjective ("-ing", "the one who…").'],
];
const VOICES = [
['middle or passive', 'The form can mean the subject acts on itself, or receives the action; context decides.'],
['middle', 'The subject acts on itself or for its own benefit.'],
['passive', 'The subject receives the action ("was done").'],
['active', 'The subject does the action.'],
];
const CASES = [
['nominative', 'The subject of the sentence, or a name for something.'],
['genitive', 'Belonging, source or description. Often "of".'],
['dative', 'The one the action is to, for or by. Often "to", "for", "in", "with".'],
['accusative', 'The direct object, the thing acted on.'],
['vocative', 'The one being spoken to.'],
];
const PARTS = [
['noun', 'A person, place or thing.'],
['verb', 'An action or state.'],
['adjective', 'Describes a noun.'],
['adverb', 'Describes a verb, adjective or another adverb.'],
['article', 'The word "the".'],
['conjunction', 'Joins words or clauses ("and", "but", "for").'],
['preposition', 'Shows how a noun relates to something else ("in", "to", "from").'],
['personal pronoun', 'Stands in for a person ("he", "you").'],
['relative pronoun', 'Links a clause back to a noun ("who", "which").'],
['demonstrative pronoun', 'Points something out ("this", "that").'],
['reflexive pronoun', 'Points back to the subject ("himself").'],
['interrogative pronoun', 'Asks a question ("who?", "what?").'],
['pronoun', 'Stands in for a noun.'],
['particle', 'A small word that adds emphasis or links ideas.'],
['interjection', 'An exclamation.'],
['numeral', 'A number.'],
];
const PREFIXES = [
['conjunctive waw', '"And" (or "but", "then") joined to the front of the next word.'],
['preposition-b', '"In", "with" or "by", joined to the front of the next word.'],
['preposition-l', '"To" or "for", joined to the front of the next word.'],
['preposition-k', '"Like" or "as", joined to the front of the next word.'],
['preposition-m', '"From", joined to the front of the next word.'],
['construct', 'Linked to the next word, usually "of".'],
];
const PERSONS = { '1st': 'I / we', first: 'I / we', '2nd': 'you', second: 'you', '3rd': 'he / she / it / they', third: 'he / she / it / they' };
const NUMBER_WORDS = { singular: 'one', plural: 'more than one', dual: 'two' };
// -> [{ term: 'Aorist', meaning: '…' }] in the order the terms appear in the label; [] when there is nothing to say.
export function grammarNotes(label) {
const text = String(label ?? '').toLowerCase();
if (!text.trim()) return [];
const isHebrewVerb = HEBREW_STEMS.some(([t]) => new RegExp(`\\b${t}\\b`).test(text));
const found = [];
const add = (table, tableName) => {
for (const [term, meaning] of table) {
const m = new RegExp(`(?:^|[^a-z-])${term.replace(/[-]/g, '[- ]?')}(?![a-z])`).exec(text);
if (m) found.push({ at: m.index, term, meaning, tableName });
}
};
add(PARTS, 'part');
if (isHebrewVerb) { add(HEBREW_STEMS, 'stem'); add(HEBREW_FORMS, 'form'); } else { add(GREEK_TENSES, 'tense'); }
add(MOODS.filter(([t]) => !isHebrewVerb || (t !== 'indicative' && t !== 'subjunctive' && t !== 'optative' && !['imperative', 'infinitive', 'participle'].includes(t))), 'mood');
if (!isHebrewVerb) add(VOICES, 'voice'); else add([['active', VOICES[3][1]], ['passive', VOICES[2][1]]], 'voice');
add(CASES, 'case');
add(PREFIXES, 'prefix');
// the same label can match a short term inside a longer one ("middle" inside "middle or passive", "pronoun" inside "personal pronoun")
const covered = (a, b) => b !== a && b.term.length > a.term.length && b.term.replace(/-/g, ' ').includes(a.term.replace(/-/g, ' ')) && a.at >= b.at - 1 && a.at < b.at + b.term.length + 1;
const kept = found.filter((a) => !found.some((b) => covered(a, b) && (b.tableName === a.tableName || b.tableName === 'prefix')));
const person = text.match(/\b(1st|2nd|3rd|first|second|third)\s+person\b/);
const number = text.match(/\b(singular|plural|dual)\b/);
const first = (t) => t.charAt(0).toUpperCase() + t.slice(1);
const notes = kept.sort((a, b) => a.at - b.at).map(({ term, meaning }) => ({ term: first(term), meaning }));
if (person) notes.push({ term: `${person[1]} person${number ? ` ${number[1]}` : ''}`.replace(/^\w/, (c) => c.toUpperCase()), meaning: `${PERSONS[person[1]]}${number ? ` (${NUMBER_WORDS[number[1]]})` : ''}.` });
return notes;
}
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from 'vitest';
import { grammarNotes } from './grammar.js';
const terms = (label) => grammarNotes(label).map((n) => n.term);
test('a Greek verb is explained part by part', () => {
const notes = grammarNotes('Verb - Aorist Indicative Active - 3rd Person Singular');
expect(notes.map((n) => n.term)).toEqual(['Verb', 'Aorist', 'Indicative', 'Active', '3rd person singular']);
expect(notes.find((n) => n.term === 'Aorist').meaning).toMatch(/as a whole/);
expect(notes.at(-1).meaning).toBe('he / she / it / they (one).');
});
test('nouns and articles give their case', () => {
expect(terms('Noun - Nominative Masculine Singular')).toEqual(['Noun', 'Nominative']);
expect(terms('Article - Genitive Neuter Plural')).toEqual(['Article', 'Genitive']);
expect(terms('Personal / Relative Pronoun - Accusative 1st Person Singular')).toContain('Accusative');
});
test('moods, participles and voices that share words are not double counted', () => {
expect(terms('Verb - Present Participle Middle or Passive - Nominative Masculine Singular')).toEqual(['Verb', 'Present', 'Participle', 'Middle or passive', 'Nominative']);
expect(terms('Verb - Perfect Infinitive Active')).toEqual(['Verb', 'Perfect', 'Infinitive', 'Active']);
});
test('a Hebrew verb is read with Hebrew meanings, not Greek ones', () => {
const notes = grammarNotes('Verb - Qal - Perfect - third person masculine singular');
expect(notes.map((n) => n.term)).toEqual(['Verb', 'Qal', 'Perfect', 'Third person singular']);
expect(notes.find((n) => n.term === 'Perfect').meaning).toMatch(/complete/);
expect(grammarNotes('Verb - Hiphil - Imperfect - third person masculine singular').find((n) => n.term === 'Hiphil').meaning).toMatch(/Causing/);
});
test('Hebrew words with attached prefixes explain the prefix', () => {
const notes = grammarNotes('Preposition-b | Noun - feminine singular');
expect(notes.map((n) => n.term)).toEqual(expect.arrayContaining(['Noun', 'Preposition-b']));
expect(notes.find((n) => n.term === 'Preposition-b').meaning).toMatch(/joined to the front/);
});
test('nothing to explain gives an empty list', () => {
expect(grammarNotes('')).toEqual([]);
expect(grammarNotes(null)).toEqual([]);
expect(grammarNotes('???')).toEqual([]);
});
+18
View File
@@ -57,3 +57,21 @@ test('a study works offline once every book in it is saved in its translation',
test('licensed translations cannot be saved offline', () => {
expect(offlineStatus({ translation: 'ESV', chapters: [{ book: 'John', bookAbbrev: 'JHN' }] }, {}).state).toBe('licensed');
});
import { READER_TRANSLATIONS, helloaoCode } from './bibleText.js';
import { parseBibleChapter } from './bibleUtils.jsx';
test('the KJV, ASV and LSV are free translations that can be saved offline and are fetched by their helloao code', () => {
for (const [id, code] of [['KJV', 'eng_kjv'], ['ASV', 'eng_asv'], ['LSV', 'eng_lsv'], ['NET', 'eng_net'], ['BSB', 'BSB']]) {
expect(READER_TRANSLATIONS.some((t) => t.id === id)).toBe(true);
expect(helloaoCode(id)).toBe(code);
expect(canSaveOffline(id)).toBe(true);
}
expect(canSaveOffline('ESV')).toBe(false);
expect(READER_TRANSLATIONS.find((t) => t.id === 'LSV').notice).toMatch(/Covenant Press/);
});
test('a paragraph mark in the KJV text is dropped from the verse', () => {
const verses = parseBibleChapter({ chapter: { content: [{ type: 'verse', number: 16, content: [{ text: '¶ For God so loved the world' }] }] } });
expect(verses[0].text).toBe('For God so loved the world');
});
+8
View File
@@ -33,6 +33,14 @@ export function matchBooks(typed) {
return BOOKS.filter((b) => b.key.startsWith(key));
}
// The one book a typed name means when it is the book's full name or a known short form ("Rom", "1 Cor", "Jn"), never a
// mere beginning of a name: "Number" is not Numbers, "Act" is not Acts. For finding references inside prose.
export function exactBook(typed) {
const key = squash(typed);
if (!key) return null;
return BOOKS.find((b) => b.key === key) ?? (ALIASES[key] ? BOOKS.find((b) => b.abbrev === ALIASES[key]) : null) ?? null;
}
// "John 3:16" -> [{ book: 'JHN', bookName: 'John', chapter: 3, verse: 16 }]; ambiguous names ("jo")
// give several. A bare book ("romans") opens chapter 1. Chapters past the end are rejected.
export function parsePassageRef(text) {
+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
// 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);
+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(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();
});
+1
View File
@@ -94,6 +94,7 @@ export function saveProjectToStorage(project) {
lastEdited: updated.lastEdited,
chapterSummary: buildChapterSummary(updated),
series: (updated.series ?? '').trim(),
archived: !!updated.archived,
tags,
};
if (existing >= 0) {
+51
View File
@@ -0,0 +1,51 @@
// Finds Scripture references in your own writing ("see Rom 8:28-30", "Psalm 23", "1 Cor 13:4") so they can be
// linked to the verse. Only full book names and the usual short forms count, and the chapter must exist.
import { CHAPTER_COUNTS, bookOptions } from './bibleUtils.jsx';
import { exactBook } from './passageRef.js';
// Standard short forms that passageRef doesn't list because it matches the start of a name (strict matching can't).
// Left out on purpose: "Mar" (March), "Act" and "Number" (ordinary words).
const EXTRA = {
gen: 'GEN', exo: 'EXO', lev: 'LEV', num: 'NUM', deu: 'DEU', jos: 'JOS', jdg: 'JDG', rut: 'RUT', '1sa': '1SA', '2sa': '2SA', '1sam': '1SA', '2sam': '2SA',
'1ki': '1KI', '2ki': '2KI', '1kin': '1KI', '2kin': '2KI', '1ch': '1CH', '2ch': '2CH', ezr: 'EZR', neh: 'NEH', est: 'EST', pro: 'PRO', ecc: 'ECC',
isa: 'ISA', lam: 'LAM', eze: 'EZK', ezk: 'EZK', dan: 'DAN', hos: 'HOS', amo: 'AMO', oba: 'OBA', mic: 'MIC', nah: 'NAM', nam: 'NAM', hab: 'HAB',
zep: 'ZEP', hag: 'HAG', zec: 'ZEC', mal: 'MAL', luk: 'LUK', joh: 'JHN', '1co': '1CO', '2co': '2CO', gal: 'GAL', eph: 'EPH', col: 'COL',
'1th': '1TH', '2th': '2TH', '1ti': '1TI', '2ti': '2TI', tit: 'TIT', phm: 'PHM', heb: 'HEB', jas: 'JAS', '1pe': '1PE', '2pe': '2PE', '1jo': '1JN', '2jo': '2JN', '3jo': '3JN', rev: 'REV',
};
const squash = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
const bookFor = (name) => exactBook(name) ?? (EXTRA[squash(name)] ? bookOptions.find((b) => b.abbrev === EXTRA[squash(name)]) ?? null : null);
// A capitalised name (up to three words, "Song of Solomon") then a chapter, then optionally :verse and -endVerse
const RE = /\b((?:[1-3]\s?)?[A-Z][A-Za-z]*\.?(?:\s(?:of\s)?[A-Z][a-z]*\.?){0,2})\s+(\d{1,3})(?::(\d{1,3})(?:\s?[-–—]\s?(\d{1,3}))?)?/g;
// -> [{ text, start, end, book, bookName, chapter, verse, endVerse }] in the order they appear (verse/endVerse may be null)
export function findReferences(text) {
const found = [];
const source = String(text ?? '');
for (const m of source.matchAll(RE)) {
const words = m[1].split(/\s+/);
// "See Romans 8" captures "See Romans": try the name without its first words until a book matches
for (let drop = 0; drop < words.length; drop += 1) {
const name = words.slice(drop).join(' ').replace(/\.$/, '');
const book = bookFor(name);
if (!book) continue;
const chapter = Number(m[2]);
if (chapter < 1 || chapter > (CHAPTER_COUNTS[book.abbrev] ?? 0)) break;
const lead = m[1].length - words.slice(drop).join(' ').length;
const start = m.index + lead;
const verse = m[3] ? Number(m[3]) : null;
const endVerse = m[4] && Number(m[4]) > verse ? Number(m[4]) : null;
found.push({ text: source.slice(start, m.index + m[0].length), start, end: m.index + m[0].length, book: book.abbrev, bookName: book.name, chapter, verse, endVerse });
break;
}
}
return found;
}
// The references with repeats collapsed, by what they point to
export function uniqueReferences(text) {
const seen = new Set();
return findReferences(text).filter((r) => { const k = `${r.book}.${r.chapter}.${r.verse}.${r.endVerse}`; if (seen.has(k)) return false; seen.add(k); return true; });
}
export const labelFor = (r) => `${r.bookName} ${r.chapter}${r.verse ? `:${r.verse}${r.endVerse ? `–${r.endVerse}` : ''}` : ''}`;
+45
View File
@@ -0,0 +1,45 @@
import { test, expect } from 'vitest';
import { findReferences, labelFor, uniqueReferences } from './scriptureRefs.js';
import { exactBook } from './passageRef.js';
const refs = (text) => findReferences(text).map((r) => labelFor(r));
test('references of every common shape are found', () => {
expect(refs('See Romans 8:28 and Psalm 23, then 1 Cor 13:4-7.')).toEqual(['Romans 8:28', 'Psalms 23', '1 Corinthians 13:4–7']);
expect(refs('Compare Jn 3:16; Gen. 1:1 and Song of Solomon 2:4.')).toEqual(['John 3:16', 'Genesis 1:1', 'Song 2:4']);
expect(refs('Phil 2:5-11 shows it, as does Heb 11')).toEqual(['Philippians 2:5–11', 'Hebrews 11']);
});
test('standard short forms work, and look-alike words do not', () => {
expect(refs('Gen 1:1; Exod 3; Deut 6:4; 1 Sam 17; 2 Kgs 5; Zech 9:9; Rev 21:4')).toEqual(['Genesis 1:1', 'Exodus 3', 'Deuteronomy 6:4', '1 Samuel 17', '2 Kings 5', 'Zechariah 9:9', 'Revelation 21:4']);
expect(refs('Mar 5 is the date')).toEqual([]);
});
test('the matched text and its position are exact, even when a word like "See" comes first', () => {
const text = 'Look: See Romans 8:28 here';
const [r] = findReferences(text);
expect(r.text).toBe('Romans 8:28');
expect(text.slice(r.start, r.end)).toBe('Romans 8:28');
expect(r).toMatchObject({ book: 'ROM', chapter: 8, verse: 28, endVerse: null });
});
test('ordinary words and impossible chapters are not references', () => {
expect(refs('Number 5 on the list, Act 3 of the play, Chapter 4, Session 2, Day 12')).toEqual([]);
expect(refs('Romans 99:1 and John 40')).toEqual([]);
expect(refs('in 1999 and 3 days, Thanks 5')).toEqual([]);
expect(refs('')).toEqual([]);
});
test('repeats collapse and a reversed range is kept as a single verse', () => {
expect(uniqueReferences('Rom 8:28 ... Romans 8:28 ... Rom 8:29').map(labelFor)).toEqual(['Romans 8:28', 'Romans 8:29']);
expect(findReferences('Rom 8:30-28')[0]).toMatchObject({ verse: 30, endVerse: null });
});
test('only a full book name or a known short form is a book', () => {
expect(exactBook('Romans')?.abbrev).toBe('ROM');
expect(exactBook('1 Cor')?.abbrev).toBe('1CO');
expect(exactBook('Jn')?.abbrev).toBe('JHN');
expect(exactBook('Number')).toBeNull();
expect(exactBook('Act')).toBeNull();
expect(exactBook('')).toBeNull();
});
+3 -1
View File
@@ -30,11 +30,13 @@ export function groupBySeries(entries) {
return groups.has('') ? [...named, { name: '', items: groups.get('') }] : named;
}
export function filterStudies(index, { query = '', tags = [], series = '' } = {}) {
// archived: false (default) leaves out finished/archived studies, true keeps only them, null keeps all.
export function filterStudies(index, { query = '', tags = [], series = '', archived = false } = {}) {
const raw = String(query).trim().toLowerCase();
const tagOnly = raw.startsWith('#');
const q = tagOnly ? raw.slice(1).trim() : raw;
return index.filter((entry) => {
if (archived !== null && !!entry.archived !== archived) return false;
if (series === '__none') { if ((entry.series ?? '').trim()) return false; } else if (series && (entry.series ?? '').trim() !== series) return false;
const own = (entry.tags ?? []).map((t) => t.toLowerCase());
if (tags.length && !tags.every((t) => own.includes(t.toLowerCase()))) return false;
+8
View File
@@ -52,3 +52,11 @@ test('a series can be chosen as a filter, and the search finds studies by series
expect(filterStudies(idx, { series: '__none' }).map((e) => e.id)).toEqual(['b']);
expect(filterStudies(idx, { query: 'advent' }).map((e) => e.id)).toEqual(['c']);
});
test('archived studies are left out unless asked for', () => {
const idx = [{ id: 'a', title: 'Live' }, { id: 'b', title: 'Done', archived: true }, { id: 'c', title: 'Done too', archived: true, tags: ['hope'] }];
expect(filterStudies(idx).map((e) => e.id)).toEqual(['a']);
expect(filterStudies(idx, { archived: true }).map((e) => e.id)).toEqual(['b', 'c']);
expect(filterStudies(idx, { archived: true, tags: ['hope'] }).map((e) => e.id)).toEqual(['c']);
expect(filterStudies(idx, { archived: null }).map((e) => e.id)).toEqual(['a', 'b', 'c']);
});
+3 -1
View File
@@ -1,8 +1,10 @@
// Fetches a chapter's raw text for building a study (the free Bible source). The Reader's "Save for
// offline" downloads go through the same address, so a saved book builds a study with no connection.
// Failures say what's wrong and what to do, instead of a generic error.
import { helloaoCode } from './bibleText.js';
export async function fetchStudyChapter(translation, bookAbbrev, chapter, { signal } = {}) {
const code = translation === 'NET' ? 'eng_net' : (translation || 'BSB');
const code = helloaoCode(translation || 'BSB');
let res;
try {
res = await fetch(`https://bible.helloao.org/api/${code}/${bookAbbrev}/${chapter}.json`, { signal });