diff --git a/server/prayers.js b/server/prayers.js index 233882a..4b1fa1a 100644 --- a/server/prayers.js +++ b/server/prayers.js @@ -28,6 +28,9 @@ export function cleanPrayer(raw, now = Date.now()) { createdAt: time(raw.createdAt, updatedAt), updatedAt, answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null, answerNote: text(raw.answerNote, 1000).trim(), + status: raw.status === 'waiting' || raw.status === 'partial' ? raw.status : '', + pinned: raw.pinned === true, + rank: int(raw.rank, 0, 100_000) ? raw.rank : null, }; } diff --git a/server/prayers.test.js b/server/prayers.test.js index fe2d810..4d2b064 100644 --- a/server/prayers.test.js +++ b/server/prayers.test.js @@ -37,3 +37,8 @@ test('a deleted prayer is not brought back by an older copy, and old markers are test('open prayers are the ones not answered or deleted', () => { expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']); }); + +test('pin, rank and status are kept when valid and dropped when not', () => { + expect(cleanPrayer(p('a', { pinned: true, rank: 2, status: 'partial' }), NOW)).toMatchObject({ pinned: true, rank: 2, status: 'partial' }); + expect(cleanPrayer(p('a', { pinned: 'yes', rank: -4, status: 'bogus' }), NOW)).toMatchObject({ pinned: false, rank: null, status: '' }); +}); diff --git a/src/components/PinnedPrayers.jsx b/src/components/PinnedPrayers.jsx new file mode 100644 index 0000000..f6465f7 --- /dev/null +++ b/src/components/PinnedPrayers.jsx @@ -0,0 +1,37 @@ +// Home card: the prayers you pinned, in the order you set, with a one-tap "I prayed". Hidden when nothing is pinned. +import { useApp } from '../context/AppContext.js'; +import { STATUSES, pinned } from '../utils/prayers.js'; +import { localDateString } from '../utils/readingPlans.js'; + +export default function PinnedPrayers() { + const { prayers, togglePrayed, openPrayer } = useApp(); + const list = pinned(prayers).slice(0, 5); + if (!list.length) return null; + const today = localDateString(); + return ( +
+
+ Praying for + +
+ +
+ ); +} diff --git a/src/components/PinnedPrayers.test.jsx b/src/components/PinnedPrayers.test.jsx new file mode 100644 index 0000000..7349ab2 --- /dev/null +++ b/src/components/PinnedPrayers.test.jsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect } from 'vitest'; +import { AppContext } from '../context/AppContext.js'; +import PinnedPrayers from './PinnedPrayers.jsx'; +import { markAnswered, newPrayer, setPinned } from '../utils/prayers.js'; + +const T0 = Date.UTC(2026, 9, 1); +const show = (prayers) => { + const ctx = { prayers, togglePrayed: vi.fn(), openPrayer: vi.fn() }; + const view = render(); + return { ctx, view }; +}; + +test('shows nothing when no prayer is pinned', () => { + const { view } = show([newPrayer({ text: 'Unpinned' }, T0)]); + expect(view.container).toBeEmptyDOMElement(); +}); + +test('lists open pinned prayers in order, lets you mark one prayed, and opens the journal', async () => { + const a = newPrayer({ text: 'For Sam', forWhom: 'Sam' }, T0); + const b = newPrayer({ text: 'Wisdom' }, T0 + 1); + const c = newPrayer({ text: 'Done already' }, T0 + 2); + let list = [a, b, c]; + list = setPinned(setPinned(setPinned(list, b.id, true, T0 + 5), a.id, true, T0 + 6), c.id, true, T0 + 7); + list = list.map((p) => (p.id === c.id ? markAnswered(p, '', T0 + 9) : p)); + const user = userEvent.setup(); + const { ctx } = show(list); + const rows = screen.getAllByRole('listitem'); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveTextContent('Wisdom'); + expect(rows[1]).toHaveTextContent('Sam'); + await user.click(screen.getAllByRole('button', { name: /I prayed/ })[0]); + expect(ctx.togglePrayed).toHaveBeenCalledWith(b.id); + await user.click(screen.getByRole('button', { name: /Open journal/ })); + expect(ctx.openPrayer).toHaveBeenCalled(); +}); diff --git a/src/hooks/usePrayers.js b/src/hooks/usePrayers.js index 0b56965..9003c46 100644 --- a/src/hooks/usePrayers.js +++ b/src/hooks/usePrayers.js @@ -3,7 +3,7 @@ // added on your phone appears on your computer. Signing out clears this device's copy. import { useCallback, useEffect, useRef, useState } from 'react'; import { getRemotePrayers, saveRemotePrayers } from '../syncService.js'; -import { markAnswered, markDeleted, mergePrayers, newPrayer, prayersEqual, reopen, touch, togglePrayedToday } from '../utils/prayers.js'; +import { markAnswered, markDeleted, mergePrayers, movePinned, newPrayer, prayersEqual, reopen, setPinned, setStatus, touch, togglePrayedToday } from '../utils/prayers.js'; import { localDateString } from '../utils/readingPlans.js'; const KEY = 'prayers'; @@ -42,6 +42,9 @@ export function usePrayers() { const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString())); const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note)); const reopenPrayer = (id) => replace(id, (p) => reopen(p)); + const pinPrayer = (id, on) => set(setPinned(ref.current, id, on)); + const movePrayer = (id, dir) => set(movePinned(ref.current, id, dir)); + const setPrayerStatus = (id, status, note) => replace(id, (p) => setStatus(p, status, note)); // Deleting leaves a marker so the other devices drop it too; the returned function puts it back const deletePrayer = (id) => { const before = ref.current.find((p) => p.id === id); @@ -70,5 +73,5 @@ export function usePrayers() { }, []); useEffect(() => () => window.clearTimeout(timerRef.current), []); - return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop }; + return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, pinPrayer, movePrayer, setPrayerStatus, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop }; } diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index f39d297..ea47939 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -3,6 +3,7 @@ import { useApp } from '../context/AppContext.js'; import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js'; import BookCover from '../components/BookCover.jsx'; import DailyFive from '../components/DailyFive.jsx'; +import PinnedPrayers from '../components/PinnedPrayers.jsx'; import WeeklyRecall from '../components/WeeklyRecall.jsx'; import OfflineBadge from '../components/OfflineBadge.jsx'; import { useOfflineSave } from '../hooks/useOfflineSave.js'; @@ -156,6 +157,7 @@ export default function HomePage() {

); })()} + diff --git a/src/pages/PrayerPage.jsx b/src/pages/PrayerPage.jsx index e4f7837..589c4f3 100644 --- a/src/pages/PrayerPage.jsx +++ b/src/pages/PrayerPage.jsx @@ -1,10 +1,10 @@ // The prayer journal: what you are praying about (and for whom), the verses tied to each request, the days you // prayed, and the answers when they come. -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useApp } from '../context/AppContext.js'; import PageShell from '../components/study/PageShell.jsx'; import { useToast } from '../components/Toasts.jsx'; -import { filterPrayers, daysToAnswer, labelFor, live, parseVerses, prayersToMarkdown } from '../utils/prayers.js'; +import { STATUSES, filterPrayers, daysToAnswer, labelFor, live, parsePrayerImport, parseVerses, pinned as pinnedList, prayersToJson, prayersToMarkdown } from '../utils/prayers.js'; import { localDateString } from '../utils/readingPlans.js'; const field = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; @@ -23,11 +23,12 @@ function VerseChips({ verses, onOpen }) { ); } -function PrayerCard({ prayer, today, actions, onOpenVerse }) { - const [mode, setMode] = useState(null); // null | 'edit' | 'answer' +function PrayerCard({ prayer, today, actions, onOpenVerse, pinPos }) { + const [mode, setMode] = useState(null); // null | 'edit' | 'answer' | 'partial' const [text, setText] = useState(''); const [note, setNote] = useState(''); const prayedToday = prayer.prayedOn.includes(today); + const isPinned = !!pinPos; const days = daysToAnswer(prayer); return (
  • @@ -41,11 +42,23 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) { ) : (
    - {prayer.forWhom &&

    For {prayer.forWhom}

    } + {(prayer.forWhom || isPinned || prayer.status) && ( +

    + {isPinned && Pinned} + {prayer.status && {STATUSES[prayer.status]}} + {prayer.forWhom && For {prayer.forWhom}} +

    + )}

    {prayer.text}

    )} + {prayer.status === 'partial' && prayer.answerNote && mode !== 'partial' && ( +
    +

    Partly answered

    +

    {prayer.answerNote}

    +
    + )} {prayer.answeredAt && (

    Answered {new Date(prayer.answeredAt).toLocaleDateString()}{days != null ? ` · after ${days} day${days === 1 ? '' : 's'}` : ''}

    @@ -61,6 +74,15 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
    )} + {mode === 'partial' && ( +
    +