Compare 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
19 changed files with 537 additions and 113 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, createdAt: time(raw.createdAt, updatedAt), updatedAt,
answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null, answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null,
answerNote: text(raw.answerNote, 1000).trim(), answerNote: text(raw.answerNote, 1000).trim(),
status: raw.status === 'waiting' || raw.status === 'partial' ? raw.status : '',
pinned: raw.pinned === true,
rank: int(raw.rank, 0, 100_000) ? raw.rank : null,
}; };
} }
+5
View File
@@ -37,3 +37,8 @@ test('a deleted prayer is not brought back by an older copy, and old markers are
test('open prayers are the ones not answered or deleted', () => { test('open prayers are the ones not answered or deleted', () => {
expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']); expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']);
}); });
test('pin, rank and status are kept when valid and dropped when not', () => {
expect(cleanPrayer(p('a', { pinned: true, rank: 2, status: 'partial' }), NOW)).toMatchObject({ pinned: true, rank: 2, status: 'partial' });
expect(cleanPrayer(p('a', { pinned: 'yes', rank: -4, status: 'bogus' }), NOW)).toMatchObject({ pinned: false, rank: null, status: '' });
});
+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); const { counts, total, recent } = recallSummary(history);
return ( 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"> <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><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"> <span className="flex items-center gap-1" aria-label="Last eight weeks">
+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"> <section className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Daily verse and devotional">
{verse && ( {verse && (
<div> <div>
<div className="flex items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day</p> <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"> <span className="flex items-center gap-1.5">
<select aria-label="Verse of the day background" value={bgChoice} onChange={(e) => pickBackground(e.target.value)} <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"> className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 text-xs">
+5 -2
View File
@@ -3,7 +3,7 @@
// added on your phone appears on your computer. Signing out clears this device's copy. // added on your phone appears on your computer. Signing out clears this device's copy.
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { getRemotePrayers, saveRemotePrayers } from '../syncService.js'; import { getRemotePrayers, saveRemotePrayers } from '../syncService.js';
import { markAnswered, markDeleted, mergePrayers, newPrayer, prayersEqual, reopen, touch, togglePrayedToday } from '../utils/prayers.js'; import { markAnswered, markDeleted, mergePrayers, movePinned, newPrayer, prayersEqual, reopen, setPinned, setStatus, touch, togglePrayedToday } from '../utils/prayers.js';
import { localDateString } from '../utils/readingPlans.js'; import { localDateString } from '../utils/readingPlans.js';
const KEY = 'prayers'; const KEY = 'prayers';
@@ -42,6 +42,9 @@ export function usePrayers() {
const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString())); const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString()));
const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note)); const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note));
const reopenPrayer = (id) => replace(id, (p) => reopen(p)); const reopenPrayer = (id) => replace(id, (p) => reopen(p));
const pinPrayer = (id, on) => set(setPinned(ref.current, id, on));
const movePrayer = (id, dir) => set(movePinned(ref.current, id, dir));
const setPrayerStatus = (id, status, note) => replace(id, (p) => setStatus(p, status, note));
// Deleting leaves a marker so the other devices drop it too; the returned function puts it back // Deleting leaves a marker so the other devices drop it too; the returned function puts it back
const deletePrayer = (id) => { const deletePrayer = (id) => {
const before = ref.current.find((p) => p.id === id); const before = ref.current.find((p) => p.id === id);
@@ -70,5 +73,5 @@ export function usePrayers() {
}, []); }, []);
useEffect(() => () => window.clearTimeout(timerRef.current), []); useEffect(() => () => window.clearTimeout(timerRef.current), []);
return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop }; return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, pinPrayer, movePrayer, setPrayerStatus, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop };
} }
+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). // Changes one top-level field of a stored study (from Home, without opening it) and syncs it.
const setProjectSeries = (id, series) => { const patchStoredStudy = (id, patch) => {
const loaded = loadProjectById(id); const loaded = loadProjectById(id);
if (!loaded) return; if (!loaded) return;
const name = String(series ?? '').trim().slice(0, 80); if (Object.entries(patch).every(([k, v]) => (loaded[k] ?? (typeof v === 'boolean' ? false : '')) === v)) return;
if ((loaded.series ?? '') === name) return; const updated = { ...loaded, ...patch, lastEdited: Date.now() };
const updated = { ...loaded, series: name, lastEdited: Date.now() };
saveProjectToStorage(updated); saveProjectToStorage(updated);
unsyncedStudies.add(id); unsyncedStudies.add(id);
setProjectIndex(loadProjectIndex()); 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) => { saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
if (result.ok) { if (result.ok) {
const serverEdited = result.data?.lastEdited ?? updated.lastEdited; 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. // 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 updateStoredChunk = (id, chunkId, change) => {
const loaded = loadProjectById(id); const loaded = loadProjectById(id);
@@ -318,6 +323,7 @@ export function useProjectLibrary({
openStudyChunk, openStudyChunk,
renameProjectInStorage, renameProjectInStorage,
setProjectSeries, setProjectSeries,
setProjectArchived,
saveChunkRecall, saveChunkRecall,
saveChunkPatch, saveChunkPatch,
deleteProject, deleteProject,
+14
View File
@@ -105,3 +105,17 @@ test('studies only on the server are pulled down', async () => {
setup(); setup();
await waitFor(() => expect(loadProjectById('far')?.title).toBe('From elsewhere')); 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);
});
+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 { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
import BookCover from '../components/BookCover.jsx'; import BookCover from '../components/BookCover.jsx';
import DailyFive from '../components/DailyFive.jsx'; import DailyFive from '../components/DailyFive.jsx';
import PinnedPrayers from '../components/PinnedPrayers.jsx';
import WeeklyRecall from '../components/WeeklyRecall.jsx'; import WeeklyRecall from '../components/WeeklyRecall.jsx';
import OfflineBadge from '../components/OfflineBadge.jsx'; import OfflineBadge from '../components/OfflineBadge.jsx';
import { useOfflineSave } from '../hooks/useOfflineSave.js'; import { useOfflineSave } from '../hooks/useOfflineSave.js';
@@ -49,6 +50,7 @@ export default function HomePage() {
resumeProject, resumeProject,
renameProjectInStorage, renameProjectInStorage,
setProjectSeries, setProjectSeries,
setProjectArchived,
deleteProject, deleteProject,
handlePlayBookAudio, handlePlayBookAudio,
handleStopBookAudio, handleStopBookAudio,
@@ -67,6 +69,7 @@ export default function HomePage() {
}); // 'recent' | 'title' | 'passage' }); // 'recent' | 'title' | 'passage'
const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them
const [showAllTags, setShowAllTags] = useState(false); 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 [seriesFilter, setSeriesFilter] = useState(''); // '' = all, '__none' = not in a series, or a series name
const [seriesEditingId, setSeriesEditingId] = useState(null); const [seriesEditingId, setSeriesEditingId] = useState(null);
const [seriesValue, setSeriesValue] = useState(''); const [seriesValue, setSeriesValue] = useState('');
@@ -118,25 +121,95 @@ export default function HomePage() {
}, 300); }, 300);
}, [homeSearch]); }, [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 shownTags = showAllTags ? tagList : tagList.slice(0, 12);
const seriesNames = seriesList(projectIndex); const seriesNames = seriesList(activeIndex);
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter }) const byHomeSort = (a, b) => {
.sort((a, b) => { if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? ''); if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? ''); return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
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 // 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 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 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 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 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 ( return (
<PageShell <PageShell
title="My Studies" title="My Studies"
active="studies" active="studies"
maxWidth="max-w-6xl xl:max-w-[88rem]"
actions={( actions={(
<> <>
<button type="button" onClick={() => setCurrentPage('insights')} className={linkBtn} title="This week, themes, words, what slipped, follow-ups">Insights</button> <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]"> <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]">
<section className="min-w-0"> {/* Left column: the recall and daily-verse cards (collapses when neither has anything to show) */}
{projectIndex.length > 0 && (() => { <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">
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 />
<WeeklyRecall /> <WeeklyRecall />
<RecallHistory />
<IqDailyCard /> <IqDailyCard />
</aside>
<section className="min-w-0 lg:pr-6">
<DailyFive />
{autoRestoredCount !== null && ( {autoRestoredCount !== null && (
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div> <div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
)} )}
@@ -236,7 +303,7 @@ export default function HomePage() {
</div> </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)]"> <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()}”` : ''}.{' '} 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> <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> </h3>
)} )}
<div className={homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}> <div className={homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}>
{section.items.map((entry) => ( {section.items.map(renderEntry)}
<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>
))}
</div> </div>
</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> </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} <TodayCard planProgress={planProgress} readingActivity={readingActivity} openReadingPlans={openReadingPlans}
openPlanReading={openPlanReading} studyReadingPlanDay={studyReadingPlanDay} studyBuildBusy={studyBuildBusy} /> openPlanReading={openPlanReading} studyReadingPlanDay={studyReadingPlanDay} studyBuildBusy={studyBuildBusy} />
<PinnedPrayers />
<RecallHistory />
{/* BSB audio player — always shown, so it's easy to find */} {/* BSB audio player — always shown, so it's easy to find */}
<AudioPlayer <AudioPlayer
title="Listen to BSB Audio" 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> </aside>
</main> </main>
</PageShell> </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 // The prayer journal: what you are praying about (and for whom), the verses tied to each request, the days you
// prayed, and the answers when they come. // prayed, and the answers when they come.
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx'; import PageShell from '../components/study/PageShell.jsx';
import { useToast } from '../components/Toasts.jsx'; import { useToast } from '../components/Toasts.jsx';
import { filterPrayers, daysToAnswer, labelFor, live, parseVerses, prayersToMarkdown } from '../utils/prayers.js'; import { STATUSES, filterPrayers, daysToAnswer, labelFor, live, parsePrayerImport, parseVerses, pinned as pinnedList, prayersToJson, prayersToMarkdown } from '../utils/prayers.js';
import { localDateString } from '../utils/readingPlans.js'; import { localDateString } from '../utils/readingPlans.js';
const field = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; const field = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
@@ -23,11 +23,12 @@ function VerseChips({ verses, onOpen }) {
); );
} }
function PrayerCard({ prayer, today, actions, onOpenVerse }) { function PrayerCard({ prayer, today, actions, onOpenVerse, pinPos }) {
const [mode, setMode] = useState(null); // null | 'edit' | 'answer' const [mode, setMode] = useState(null); // null | 'edit' | 'answer' | 'partial'
const [text, setText] = useState(''); const [text, setText] = useState('');
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const prayedToday = prayer.prayedOn.includes(today); const prayedToday = prayer.prayedOn.includes(today);
const isPinned = !!pinPos;
const days = daysToAnswer(prayer); const days = daysToAnswer(prayer);
return ( return (
<li className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4"> <li className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4">
@@ -41,11 +42,23 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
</div> </div>
) : ( ) : (
<div> <div>
{prayer.forWhom && <p className="text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">For {prayer.forWhom}</p>} {(prayer.forWhom || isPinned || prayer.status) && (
<p className="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">
{isPinned && <span className="text-[var(--r-accent)]">Pinned</span>}
{prayer.status && <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 normal-case tracking-normal">{STATUSES[prayer.status]}</span>}
{prayer.forWhom && <span>For {prayer.forWhom}</span>}
</p>
)}
<p className="whitespace-pre-wrap text-[15px] leading-relaxed">{prayer.text}</p> <p className="whitespace-pre-wrap text-[15px] leading-relaxed">{prayer.text}</p>
<VerseChips verses={prayer.verses} onOpen={onOpenVerse} /> <VerseChips verses={prayer.verses} onOpen={onOpenVerse} />
</div> </div>
)} )}
{prayer.status === 'partial' && prayer.answerNote && mode !== 'partial' && (
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note">
<p className="text-xs font-medium text-[var(--r-accent)]">Partly answered</p>
<p className="mt-0.5 whitespace-pre-wrap">{prayer.answerNote}</p>
</div>
)}
{prayer.answeredAt && ( {prayer.answeredAt && (
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note"> <div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note">
<p className="text-xs font-medium text-[var(--r-accent)]">Answered {new Date(prayer.answeredAt).toLocaleDateString()}{days != null ? ` · after ${days} day${days === 1 ? '' : 's'}` : ''}</p> <p className="text-xs font-medium text-[var(--r-accent)]">Answered {new Date(prayer.answeredAt).toLocaleDateString()}{days != null ? ` · after ${days} day${days === 1 ? '' : 's'}` : ''}</p>
@@ -61,6 +74,15 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
</div> </div>
</div> </div>
)} )}
{mode === 'partial' && (
<div className="mt-3">
<textarea aria-label="What has been answered so far?" autoFocus value={note} onChange={(e) => setNote(e.target.value)} rows={2} maxLength={1000} placeholder="What has been answered so far? (optional)" className={`${field} w-full`} />
<div className="mt-2 flex gap-3 text-xs">
<button type="button" onClick={() => { actions.status(prayer.id, 'partial', note); setMode(null); }} className="font-medium text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setMode(null)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="mt-3 flex flex-wrap items-center gap-2">
{!prayer.answeredAt && ( {!prayer.answeredAt && (
<button type="button" onClick={() => actions.toggle(prayer.id)} aria-pressed={prayedToday} className={`${small} ${prayedToday ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}> <button type="button" onClick={() => actions.toggle(prayer.id)} aria-pressed={prayedToday} className={`${small} ${prayedToday ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>
@@ -68,7 +90,20 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
</button> </button>
)} )}
{prayer.prayedOn.length > 0 && <span className="text-xs text-[var(--r-sub)]">Prayed on {prayer.prayedOn.length} day{prayer.prayedOn.length === 1 ? '' : 's'}</span>} {prayer.prayedOn.length > 0 && <span className="text-xs text-[var(--r-sub)]">Prayed on {prayer.prayedOn.length} day{prayer.prayedOn.length === 1 ? '' : 's'}</span>}
<span className="ml-auto flex gap-2"> <span className="ml-auto flex flex-wrap gap-2">
{!prayer.answeredAt && isPinned && (
<>
<button type="button" onClick={() => actions.move(prayer.id, -1)} disabled={pinPos.index === 0} aria-label="Move up" title="Move up" className={`${small} disabled:opacity-40`}>↑</button>
<button type="button" onClick={() => actions.move(prayer.id, 1)} disabled={pinPos.index === pinPos.count - 1} aria-label="Move down" title="Move down" className={`${small} disabled:opacity-40`}>↓</button>
</>
)}
{!prayer.answeredAt && <button type="button" onClick={() => actions.pin(prayer.id, !isPinned)} aria-pressed={isPinned} className={`${small} ${isPinned ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>{isPinned ? 'Unpin' : 'Pin'}</button>}
{!prayer.answeredAt && (
<>
<button type="button" onClick={() => actions.status(prayer.id, prayer.status === 'waiting' ? '' : 'waiting')} aria-pressed={prayer.status === 'waiting'} className={`${small} ${prayer.status === 'waiting' ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>Waiting</button>
<button type="button" onClick={() => { setNote(prayer.status === 'partial' ? prayer.answerNote : ''); setMode('partial'); }} aria-pressed={prayer.status === 'partial'} className={`${small} ${prayer.status === 'partial' ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>Partly answered</button>
</>
)}
{prayer.answeredAt {prayer.answeredAt
? <button type="button" onClick={() => actions.reopen(prayer.id)} className={small}>Pray again</button> ? <button type="button" onClick={() => actions.reopen(prayer.id)} className={small}>Pray again</button>
: <button type="button" onClick={() => { setNote(''); setMode('answer'); }} className={small}>Answered</button>} : <button type="button" onClick={() => { setNote(''); setMode('answer'); }} className={small}>Answered</button>}
@@ -81,12 +116,13 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
} }
export default function PrayerPage() { export default function PrayerPage() {
const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, prayerDraft, clearPrayerDraft, jumpToReaderVerse, openBibleReader } = useApp(); const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, pinPrayer, movePrayer, setPrayerStatus, importPrayers, prayerDraft, clearPrayerDraft, jumpToReaderVerse, openBibleReader } = useApp();
const { toast } = useToast(); const { toast } = useToast();
const [tab, setTab] = useState('open'); const [tab, setTab] = useState('open');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [form, setForm] = useState({ text: '', forWhom: '', verses: '' }); const [form, setForm] = useState({ text: '', forWhom: '', verses: '' });
const today = localDateString(); const today = localDateString();
const fileRef = useRef(null);
// "Pray about this verse" from the Reader arrives with the verse filled in // "Pray about this verse" from the Reader arrives with the verse filled in
useEffect(() => { useEffect(() => {
@@ -105,19 +141,31 @@ export default function PrayerPage() {
setTab('open'); setTab('open');
}; };
const actions = { const actions = {
edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer, edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer, pin: pinPrayer, move: movePrayer, status: setPrayerStatus,
answer: (id, note) => { answerPrayer(id, note); toast('Marked answered. Thank God for it.'); }, answer: (id, note) => { answerPrayer(id, note); toast('Marked answered. Thank God for it.'); },
remove: (id) => { const undo = deletePrayer(id); toast('Prayer deleted', { action: { label: 'Undo', onClick: undo } }); }, remove: (id) => { const undo = deletePrayer(id); toast('Prayer deleted', { action: { label: 'Undo', onClick: undo } }); },
}; };
const openVerse = (book, chapter, verse) => { jumpToReaderVerse(book, chapter, verse); openBibleReader(); }; const openVerse = (book, chapter, verse) => { jumpToReaderVerse(book, chapter, verse); openBibleReader(); };
const download = () => { const pinOrder = useMemo(() => pinnedList(prayers).map((p) => p.id), [prayers]);
const url = URL.createObjectURL(new Blob([prayersToMarkdown(prayers)], { type: 'text/markdown;charset=utf-8' })); const download = (json = false) => {
const url = URL.createObjectURL(new Blob([json ? prayersToJson(prayers) : prayersToMarkdown(prayers)], { type: json ? 'application/json' : 'text/markdown;charset=utf-8' }));
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.download = 'prayer-journal.md'; a.href = url; a.download = json ? 'prayer-journal.json' : 'prayer-journal.md';
document.body.appendChild(a); a.click(); a.remove(); document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000); setTimeout(() => URL.revokeObjectURL(url), 1000);
}; };
const onImportFile = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
let result;
try { result = parsePrayerImport(await file.text(), prayers); } catch { result = { prayers: [], skipped: 0, error: 'Couldn’t read that file.' }; }
if (result.error) { toast(result.error); return; }
if (result.prayers.length) importPrayers(result.prayers);
toast(`Imported ${result.prayers.length} prayer${result.prayers.length === 1 ? '' : 's'}${result.skipped ? ` (${result.skipped} already here or skipped)` : ''}`);
};
return ( return (
<PageShell title="Prayer journal" active="prayer" maxWidth="max-w-3xl"> <PageShell title="Prayer journal" active="prayer" maxWidth="max-w-3xl">
<main className="mx-auto max-w-3xl space-y-5 px-4 py-6"> <main className="mx-auto max-w-3xl space-y-5 px-4 py-6">
@@ -149,7 +197,14 @@ export default function PrayerPage() {
<button type="button" role="tab" aria-selected={tab === 'all'} onClick={() => setTab('all')} className={tabBtn(tab === 'all')}>All</button> <button type="button" role="tab" aria-selected={tab === 'all'} onClick={() => setTab('all')} className={tabBtn(tab === 'all')}>All</button>
</div> </div>
<input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search prayers…" aria-label="Search prayers" className={`${field} ml-auto w-48 py-1.5`} /> <input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search prayers…" aria-label="Search prayers" className={`${field} ml-auto w-48 py-1.5`} />
{live(prayers).length > 0 && <button type="button" onClick={download} className={small}>Download</button>} <button type="button" onClick={() => fileRef.current?.click()} className={small}>Import</button>
<input ref={fileRef} type="file" accept=".json,.md,.txt,text/markdown,application/json,text/plain" onChange={onImportFile} className="hidden" aria-label="Import prayers file" />
{live(prayers).length > 0 && (
<>
<button type="button" onClick={() => download(false)} className={small}>Download</button>
<button type="button" onClick={() => download(true)} className={small} title="Keeps every detail, including the days you prayed">Download JSON</button>
</>
)}
</div> </div>
{shown.length === 0 ? ( {shown.length === 0 ? (
@@ -159,7 +214,7 @@ export default function PrayerPage() {
</p> </p>
) : ( ) : (
<ul className="space-y-3" aria-label="Prayers"> <ul className="space-y-3" aria-label="Prayers">
{shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} />)} {shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} pinPos={pinOrder.includes(p.id) ? { index: pinOrder.indexOf(p.id), count: pinOrder.length } : null} />)}
</ul> </ul>
)} )}
</main> </main>
+32 -2
View File
@@ -4,14 +4,14 @@ import { vi, test, expect } from 'vitest';
import { AppContext } from '../context/AppContext.js'; import { AppContext } from '../context/AppContext.js';
import { ToastProvider } from '../components/Toasts.jsx'; import { ToastProvider } from '../components/Toasts.jsx';
import PrayerPage from './PrayerPage.jsx'; import PrayerPage from './PrayerPage.jsx';
import { markAnswered, newPrayer } from '../utils/prayers.js'; import { markAnswered, newPrayer, setPinned } from '../utils/prayers.js';
vi.mock('../components/study/PageShell.jsx', () => ({ default: ({ children }) => <div>{children}</div> })); vi.mock('../components/study/PageShell.jsx', () => ({ default: ({ children }) => <div>{children}</div> }));
const T0 = Date.UTC(2026, 9, 1); const T0 = Date.UTC(2026, 9, 1);
function setup(initial = [], extra = {}) { function setup(initial = [], extra = {}) {
const ctx = { const ctx = {
prayers: initial, addPrayer: vi.fn(), editPrayer: vi.fn(), togglePrayed: vi.fn(), answerPrayer: vi.fn(), reopenPrayer: vi.fn(), deletePrayer: vi.fn(() => vi.fn()), prayers: initial, addPrayer: vi.fn(), editPrayer: vi.fn(), togglePrayed: vi.fn(), answerPrayer: vi.fn(), reopenPrayer: vi.fn(), pinPrayer: vi.fn(), movePrayer: vi.fn(), setPrayerStatus: vi.fn(), importPrayers: vi.fn(), deletePrayer: vi.fn(() => vi.fn()),
prayerDraft: null, clearPrayerDraft: vi.fn(), jumpToReaderVerse: vi.fn(), openBibleReader: vi.fn(), ...extra, prayerDraft: null, clearPrayerDraft: vi.fn(), jumpToReaderVerse: vi.fn(), openBibleReader: vi.fn(), ...extra,
}; };
render(<AppContext.Provider value={ctx}><ToastProvider><PrayerPage /></ToastProvider></AppContext.Provider>); render(<AppContext.Provider value={ctx}><ToastProvider><PrayerPage /></ToastProvider></AppContext.Provider>);
@@ -78,3 +78,33 @@ test('search narrows the list', async () => {
expect(screen.getByText('Rain for the farm')).toBeInTheDocument(); expect(screen.getByText('Rain for the farm')).toBeInTheDocument();
expect(screen.queryByText('Peace at home')).toBeNull(); expect(screen.queryByText('Peace at home')).toBeNull();
}); });
test('prayers can be pinned, reordered, and marked waiting or partly answered', async () => {
const a = newPrayer({ text: 'First request' }, T0);
const b = newPrayer({ text: 'Second request' }, T0 + 1);
const list = setPinned(setPinned([a, b], a.id, true, T0 + 5), b.id, true, T0 + 6);
const user = userEvent.setup();
const ctx = setup(list);
const items = screen.getAllByRole('listitem');
expect(items[0]).toHaveTextContent('First request');
expect(within(items[0]).getByRole('button', { name: 'Move up' })).toBeDisabled();
await user.click(within(items[0]).getByRole('button', { name: 'Move down' }));
expect(ctx.movePrayer).toHaveBeenCalledWith(a.id, 1);
await user.click(within(items[1]).getByRole('button', { name: 'Unpin' }));
expect(ctx.pinPrayer).toHaveBeenCalledWith(b.id, false);
await user.click(within(items[0]).getByRole('button', { name: 'Waiting' }));
expect(ctx.setPrayerStatus).toHaveBeenCalledWith(a.id, 'waiting');
await user.click(within(items[0]).getByRole('button', { name: 'Partly answered' }));
await user.type(screen.getByLabelText('What has been answered so far?'), 'Job interview came');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(ctx.setPrayerStatus).toHaveBeenCalledWith(a.id, 'partial', 'Job interview came');
});
test('importing a file adds only new prayers', async () => {
const have = newPrayer({ text: 'Already here' }, T0);
const file = new File([JSON.stringify([{ text: 'Already here' }, { text: 'Brand new' }])], 'p.json', { type: 'application/json' });
const ctx = setup([have]);
await userEvent.setup().upload(screen.getByLabelText('Import prayers file'), file);
expect(ctx.importPrayers).toHaveBeenCalledWith([expect.objectContaining({ text: 'Brand new' })]);
expect(await screen.findByText(/Imported 1 prayer/)).toBeInTheDocument();
});
+127 -5
View File
@@ -1,7 +1,7 @@
// The prayer journal: requests you are praying about, the verses tied to them, the days you prayed, and how // The prayer journal: requests you are praying about, the verses tied to them, the days you prayed, and how
// God answered. Pure helpers for making, changing, merging and finding prayers. A prayer is // God answered. Pure helpers for making, changing, merging and finding prayers. A prayer is
// { id, text, forWhom, verses: [{ book, chapter, verse, label }], prayedOn: ['2026-10-06', ...], createdAt, updatedAt, // { id, text, forWhom, verses: [{ book, chapter, verse, label }], prayedOn: ['2026-10-06', ...], createdAt, updatedAt,
// answeredAt, answerNote }; a deleted one is just { id, deleted: true, updatedAt } so other devices drop it too. // answeredAt, answerNote, status: '' | 'waiting' | 'partial', pinned, rank }; a deleted one is just { id, deleted: true, updatedAt } so other devices drop it too.
import { formatPassage, parsePassageRef } from './passageRef.js'; import { formatPassage, parsePassageRef } from './passageRef.js';
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
@@ -10,7 +10,7 @@ const MAX_DAYS = 400;
const makeId = () => (globalThis.crypto?.randomUUID?.() ?? `p-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`); const makeId = () => (globalThis.crypto?.randomUUID?.() ?? `p-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`);
export function newPrayer({ text, forWhom = '', verses = [] }, now = Date.now()) { export function newPrayer({ text, forWhom = '', verses = [] }, now = Date.now()) {
return { id: makeId(), text: String(text).trim().slice(0, 2000), forWhom: String(forWhom).trim().slice(0, 80), verses: verses.slice(0, 8), prayedOn: [], createdAt: now, updatedAt: now, answeredAt: null, answerNote: '' }; return { id: makeId(), text: String(text).trim().slice(0, 2000), forWhom: String(forWhom).trim().slice(0, 80), verses: verses.slice(0, 8), prayedOn: [], createdAt: now, updatedAt: now, answeredAt: null, answerNote: '', status: '', pinned: false, rank: null };
} }
export const touch = (prayer, patch, now = Date.now()) => ({ ...prayer, ...patch, updatedAt: Math.max(now, (prayer.updatedAt ?? 0) + 1) }); export const touch = (prayer, patch, now = Date.now()) => ({ ...prayer, ...patch, updatedAt: Math.max(now, (prayer.updatedAt ?? 0) + 1) });
@@ -21,8 +21,33 @@ export function togglePrayedToday(prayer, today, now = Date.now()) {
return touch(prayer, { prayedOn: has ? prayer.prayedOn.filter((d) => d !== today) : [...prayer.prayedOn, today].sort().slice(-MAX_DAYS) }, now); return touch(prayer, { prayedOn: has ? prayer.prayedOn.filter((d) => d !== today) : [...prayer.prayedOn, today].sort().slice(-MAX_DAYS) }, now);
} }
export const markAnswered = (prayer, note, now = Date.now()) => touch(prayer, { answeredAt: now, answerNote: String(note ?? '').trim().slice(0, 1000) }, now); // Answering takes a prayer off the pinned list and clears any "waiting" / "partly answered" mark
export const reopen = (prayer, now = Date.now()) => touch(prayer, { answeredAt: null, answerNote: '' }, now); export const markAnswered = (prayer, note, now = Date.now()) => touch(prayer, { answeredAt: now, answerNote: String(note ?? '').trim().slice(0, 1000), status: '', pinned: false, rank: null }, now);
export const reopen = (prayer, now = Date.now()) => touch(prayer, { answeredAt: null, answerNote: '', status: '' }, now);
// Not a clean yes: "waiting" (still waiting on God) or "partial" (partly answered; the note says how). Both stay open.
export const STATUSES = { waiting: 'Waiting', partial: 'Partly answered' };
export function setStatus(prayer, status, note = '', now = Date.now()) {
const next = STATUSES[status] ? status : '';
return touch(prayer, { status: next, answerNote: next === 'partial' ? String(note ?? '').trim().slice(0, 1000) : '' }, now);
}
// Pinned prayers keep an order (`rank`, lowest first). Pinning puts one at the end; these return a new list.
export const pinned = (list) => live(list).filter((p) => p.pinned && !p.answeredAt).sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0) || (a.createdAt ?? 0) - (b.createdAt ?? 0));
export function setPinned(list, id, on, now = Date.now()) {
const top = pinned(list).reduce((m, p) => Math.max(m, p.rank ?? 0), 0);
return list.map((p) => (p.id === id && !p.deleted ? touch(p, on ? { pinned: true, rank: top + 1 } : { pinned: false, rank: null }, now) : p));
}
// dir: -1 moves the prayer up the pinned list, 1 moves it down
export function movePinned(list, id, dir, now = Date.now()) {
const order = pinned(list).map((p) => p.id);
const at = order.indexOf(id);
const to = at + dir;
if (at < 0 || to < 0 || to >= order.length) return list;
[order[at], order[to]] = [order[to], order[at]];
const rank = new Map(order.map((pid, i) => [pid, i + 1]));
return list.map((p) => (rank.has(p.id) && p.rank !== rank.get(p.id) ? touch(p, { rank: rank.get(p.id) }, now) : p));
}
// Same rules as the server's merge: the newer version of each prayer wins, days prayed are combined, deletions stick. // Same rules as the server's merge: the newer version of each prayer wins, days prayed are combined, deletions stick.
export function mergePrayers(a, b, now = Date.now()) { export function mergePrayers(a, b, now = Date.now()) {
@@ -59,7 +84,14 @@ export function filterPrayers(list, { tab = 'open', query = '' } = {}) {
return live(list) return live(list)
.filter((p) => (tab === 'open' ? !p.answeredAt : tab === 'answered' ? !!p.answeredAt : true)) .filter((p) => (tab === 'open' ? !p.answeredAt : tab === 'answered' ? !!p.answeredAt : true))
.filter((p) => !q || `${p.text} ${p.forWhom} ${p.answerNote} ${(p.verses ?? []).map((v) => v.label).join(' ')}`.toLowerCase().includes(q)) .filter((p) => !q || `${p.text} ${p.forWhom} ${p.answerNote} ${(p.verses ?? []).map((v) => v.label).join(' ')}`.toLowerCase().includes(q))
.sort((x, y) => (tab === 'answered' ? (y.answeredAt ?? 0) - (x.answeredAt ?? 0) : (y.updatedAt ?? 0) - (x.updatedAt ?? 0))); .sort((x, y) => {
if (tab === 'answered') return (y.answeredAt ?? 0) - (x.answeredAt ?? 0);
const px = x.pinned && !x.answeredAt;
const py = y.pinned && !y.answeredAt;
if (px !== py) return px ? -1 : 1;
if (px) return (x.rank ?? 0) - (y.rank ?? 0);
return (y.updatedAt ?? 0) - (x.updatedAt ?? 0);
});
} }
export const daysToAnswer = (p) => (p.answeredAt ? Math.max(0, Math.round((p.answeredAt - p.createdAt) / DAY_MS)) : null); export const daysToAnswer = (p) => (p.answeredAt ? Math.max(0, Math.round((p.answeredAt - p.createdAt) / DAY_MS)) : null);
@@ -73,6 +105,9 @@ export function prayersToMarkdown(list, { date = new Date() } = {}) {
for (const p of rows) { for (const p of rows) {
out.push(`- ${p.forWhom ? `**${p.forWhom}:** ` : ''}${p.text.replace(/\s*\n\s*/g, ' ')}${p.verses?.length ? ` (${p.verses.map(labelFor).join('; ')})` : ''}`); out.push(`- ${p.forWhom ? `**${p.forWhom}:** ` : ''}${p.text.replace(/\s*\n\s*/g, ' ')}${p.verses?.length ? ` (${p.verses.map(labelFor).join('; ')})` : ''}`);
out.push(` - Asked ${new Date(p.createdAt).toLocaleDateString()}${p.prayedOn?.length ? ` · prayed on ${p.prayedOn.length} day${p.prayedOn.length === 1 ? '' : 's'}` : ''}`); out.push(` - Asked ${new Date(p.createdAt).toLocaleDateString()}${p.prayedOn?.length ? ` · prayed on ${p.prayedOn.length} day${p.prayedOn.length === 1 ? '' : 's'}` : ''}`);
if (p.pinned && !p.answeredAt) out.push(' - Pinned');
if (p.status === 'waiting') out.push(' - Waiting');
if (p.status === 'partial') out.push(` - Partly answered${p.answerNote ? `: ${p.answerNote.replace(/\s*\n\s*/g, ' ')}` : ''}`);
if (p.answeredAt) out.push(` - Answered ${new Date(p.answeredAt).toLocaleDateString()}${p.answerNote ? `: ${p.answerNote.replace(/\s*\n\s*/g, ' ')}` : ''}`); if (p.answeredAt) out.push(` - Answered ${new Date(p.answeredAt).toLocaleDateString()}${p.answerNote ? `: ${p.answerNote.replace(/\s*\n\s*/g, ' ')}` : ''}`);
} }
out.push(''); out.push('');
@@ -82,3 +117,90 @@ export function prayersToMarkdown(list, { date = new Date() } = {}) {
if (!items.length) out.push('No prayers yet.', ''); if (!items.length) out.push('No prayers yet.', '');
return out.join('\n'); return out.join('\n');
} }
// ---- Import -------------------------------------------------------------------------------------------------
// Reads a file made by "Download" (markdown) or "Download JSON" (also a full backup, or a bare list of prayers).
// Returns { prayers, skipped } where `prayers` are new entries only: ones already in `existing` (same id, or the
// same words for the same person) are left out.
const key = (p) => `${String(p.forWhom ?? '').trim().toLowerCase()}|${String(p.text ?? '').trim().toLowerCase().replace(/\s+/g, ' ')}`;
const stamp = (v, fallback) => (Number.isFinite(v) && v > 0 ? v : fallback);
const dateMs = (str, fallback) => { const t = Date.parse(str); return Number.isFinite(t) ? t : fallback; };
function fromJson(raw, now) {
const rows = Array.isArray(raw) ? raw : Array.isArray(raw?.prayers) ? raw.prayers : null;
if (!rows) return null;
const out = [];
let skipped = 0;
for (const r of rows) {
if (!r || typeof r.text !== 'string' || !r.text.trim() || r.deleted) { skipped += 1; continue; }
const base = newPrayer({ text: r.text, forWhom: typeof r.forWhom === 'string' ? r.forWhom : '', verses: Array.isArray(r.verses) ? r.verses.filter((v) => v && typeof v.book === 'string' && Number.isInteger(v.chapter)) : [] }, now);
out.push({
...base,
id: typeof r.id === 'string' && /^[\w-]{1,64}$/.test(r.id) ? r.id : base.id,
prayedOn: (Array.isArray(r.prayedOn) ? r.prayedOn.filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)) : []).sort().slice(-MAX_DAYS),
createdAt: stamp(r.createdAt, now), updatedAt: stamp(r.updatedAt, now),
answeredAt: stamp(r.answeredAt, null), answerNote: typeof r.answerNote === 'string' ? r.answerNote.trim().slice(0, 1000) : '',
status: STATUSES[r.status] ? r.status : '', pinned: r.pinned === true && !r.answeredAt, rank: Number.isFinite(r.rank) ? r.rank : null,
});
}
return { prayers: out, skipped };
}
function fromMarkdown(text, now) {
const out = [];
let section = '';
let cur = null;
for (const line of String(text).split(/\r?\n/)) {
const head = line.match(/^##\s+(.+?)\s*$/);
if (head) { section = head[1].toLowerCase(); cur = null; continue; }
const item = line.match(/^- (?:\*\*(.+?):\*\* )?(.+)$/);
if (item && section) {
let body = item[2].trim();
let verses = [];
const tail = body.match(/\s\(([^()]*)\)$/);
if (tail) {
const r = parseVerses(tail[1]);
if (r.verses.length && !r.unknown.length) { verses = r.verses; body = body.slice(0, tail.index).trim(); }
}
cur = { ...newPrayer({ text: body, forWhom: item[1] ?? '', verses }, now) };
if (section === 'answered') cur.answeredAt = now;
out.push(cur);
continue;
}
const sub = cur && line.match(/^\s+- (.+)$/);
if (!sub) continue;
const m = sub[1];
let x;
if ((x = m.match(/^Asked (.+?)(?: · prayed on \d+ days?)?$/))) { cur.createdAt = dateMs(x[1], now); cur.updatedAt = cur.createdAt; }
else if ((x = m.match(/^Answered ([^:]+?)(?:: (.*))?$/))) { cur.answeredAt = dateMs(x[1], now); cur.answerNote = (x[2] ?? '').slice(0, 1000); }
else if (m === 'Pinned') { cur.pinned = !cur.answeredAt; }
else if (m === 'Waiting') cur.status = 'waiting';
else if ((x = m.match(/^Partly answered(?:: (.*))?$/))) { cur.status = 'partial'; cur.answerNote = (x[1] ?? '').slice(0, 1000); }
}
// assign ranks to pinned ones in file order
let rank = 0;
for (const p of out) if (p.pinned) { rank += 1; p.rank = rank; }
return { prayers: out, skipped: 0 };
}
export function parsePrayerImport(fileText, existing = [], now = Date.now()) {
const trimmed = String(fileText ?? '').trim();
let parsed = null;
if (/^[[{]/.test(trimmed)) { try { parsed = fromJson(JSON.parse(trimmed), now); } catch { parsed = null; } }
if (!parsed && !/^[[{]/.test(trimmed)) parsed = fromMarkdown(trimmed, now);
if (!parsed) return { prayers: [], skipped: 0, error: 'That file isn’t a prayer journal.' };
const have = live(existing);
const ids = new Set((existing ?? []).map((p) => p.id));
const seen = new Set(have.map(key));
const prayers = [];
let skipped = parsed.skipped;
for (const p of parsed.prayers) {
if (ids.has(p.id) || seen.has(key(p))) { skipped += 1; continue; }
seen.add(key(p));
prayers.push(p);
}
if (!prayers.length && !skipped) return { prayers, skipped, error: 'No prayers found in that file.' };
return { prayers, skipped };
}
export const prayersToJson = (list) => JSON.stringify({ kind: 'prayer-journal', version: 1, prayers: live(list) }, null, 2);
+49
View File
@@ -68,3 +68,52 @@ test('the Markdown separates what is being prayed for from what was answered', (
expect(md).toContain(': Hired!'); expect(md).toContain(': Hired!');
expect(prayersToMarkdown([])).toContain('No prayers yet.'); expect(prayersToMarkdown([])).toContain('No prayers yet.');
}); });
import { movePinned, parsePrayerImport, pinned, prayersToJson, setPinned, setStatus } from './prayers.js';
test('pinning orders prayers, moving swaps them, and answering unpins', () => {
let list = ['a', 'b', 'c'].map((t, i) => newPrayer({ text: t }, T0 + i));
const [a, b, c] = list.map((p) => p.id);
list = setPinned(list, b, true, T0 + 10);
list = setPinned(list, a, true, T0 + 11);
expect(pinned(list).map((p) => p.id)).toEqual([b, a]);
list = movePinned(list, a, -1, T0 + 12);
expect(pinned(list).map((p) => p.id)).toEqual([a, b]);
expect(movePinned(list, a, -1)).toBe(list); // already first
expect(filterPrayers(list, { tab: 'open' }).map((p) => p.id)[0]).toBe(a);
expect(filterPrayers(list, { tab: 'open' }).map((p) => p.id)).toEqual([a, b, c]);
list = list.map((p) => (p.id === a ? markAnswered(p, '', T0 + 20) : p));
expect(pinned(list).map((p) => p.id)).toEqual([b]);
});
test('waiting and partly answered stay open; partial keeps a note; answering clears them', () => {
let p = setStatus(newPrayer({ text: 'x' }, T0), 'partial', ' half way ', T0 + 1);
expect(p).toMatchObject({ status: 'partial', answerNote: 'half way', answeredAt: null });
p = setStatus(p, 'waiting', 'ignored', T0 + 2);
expect(p).toMatchObject({ status: 'waiting', answerNote: '' });
expect(setStatus(p, '', '', T0 + 3).status).toBe('');
expect(markAnswered(p, 'done', T0 + 4)).toMatchObject({ status: '', answeredAt: T0 + 4 });
});
test('import reads our markdown and JSON, and skips prayers already in the journal', () => {
const src = [
{ ...newPrayer({ text: 'Wisdom', forWhom: 'Sam', verses: [{ book: 'JAS', chapter: 1, verse: 5, label: 'James 1:5' }] }, T0), pinned: true, rank: 1, status: 'waiting' },
markAnswered(newPrayer({ text: 'A job' }, T0), 'Hired', T0 + DAY),
];
const md = prayersToMarkdown(src, { date: new Date(T0) });
const fromMd = parsePrayerImport(md, [], T0);
expect(fromMd.prayers).toHaveLength(2);
expect(fromMd.prayers[0]).toMatchObject({ text: 'Wisdom', forWhom: 'Sam', status: 'waiting', pinned: true, answeredAt: null });
expect(fromMd.prayers[0].verses[0]).toMatchObject({ book: 'JAS', chapter: 1, verse: 5 });
expect(fromMd.prayers[1]).toMatchObject({ text: 'A job', answerNote: 'Hired' });
expect(fromMd.prayers[1].answeredAt).toBeTruthy();
const fromJson = parsePrayerImport(prayersToJson(src), [], T0);
expect(fromJson.prayers.map((p) => p.id)).toEqual(src.map((p) => p.id));
expect(fromJson.prayers[0]).toMatchObject({ pinned: true, status: 'waiting' });
expect(parsePrayerImport(prayersToJson(src), [src[0]], T0)).toMatchObject({ skipped: 1, prayers: [expect.objectContaining({ text: 'A job' })] });
expect(parsePrayerImport(md, [src[0]], T0).prayers.map((p) => p.text)).toEqual(['A job']); // same words, same person
expect(parsePrayerImport('{"nope":1}', []).error).toBeTruthy();
expect(parsePrayerImport('', []).error).toBeTruthy();
});
+1
View File
@@ -94,6 +94,7 @@ export function saveProjectToStorage(project) {
lastEdited: updated.lastEdited, lastEdited: updated.lastEdited,
chapterSummary: buildChapterSummary(updated), chapterSummary: buildChapterSummary(updated),
series: (updated.series ?? '').trim(), series: (updated.series ?? '').trim(),
archived: !!updated.archived,
tags, tags,
}; };
if (existing >= 0) { if (existing >= 0) {
+3 -1
View File
@@ -30,11 +30,13 @@ export function groupBySeries(entries) {
return groups.has('') ? [...named, { name: '', items: groups.get('') }] : named; 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 raw = String(query).trim().toLowerCase();
const tagOnly = raw.startsWith('#'); const tagOnly = raw.startsWith('#');
const q = tagOnly ? raw.slice(1).trim() : raw; const q = tagOnly ? raw.slice(1).trim() : raw;
return index.filter((entry) => { 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; 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()); const own = (entry.tags ?? []).map((t) => t.toLowerCase());
if (tags.length && !tags.every((t) => own.includes(t.toLowerCase()))) return false; 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, { series: '__none' }).map((e) => e.id)).toEqual(['b']);
expect(filterStudies(idx, { query: 'advent' }).map((e) => e.id)).toEqual(['c']); 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']);
});