Compare commits

..
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 0c8a632cff Add own-work tracking, Claude hand-off check, and weekly recall
- Long pastes into Observe/Interpret/Apply are tagged as pasted; notes show
  Mine / N pasted and an own-words share. Pastes follow text through merges.
- The "-> Claude" buttons on the Script stage ask first when a chunk lacks
  enough of the user's own (non-pasted) words.
- Weekly recall card on Home: write a passage from memory, compare with notes,
  self-rate; recalls are saved on the chunk and synced.
- Recall history on Home with an eight-week strip and the full list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:25:49 -04:00
nmemmertandClaude Sonnet 5.5 edc5a65d2b Keep the verse of the day when IQ Bible is down
Build and Push Docker Image / build-and-push (push) Successful in 5m42s
- Fall back to a built-in verse (one a day, same for everyone) in the reader's own translation
- No translation picker in fallback mode; card shows even with no IQ Bible key

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:30:04 -04:00
nmemmertandClaude Sonnet 5.5 512c6b6889 Put every preference in Settings; same header on every page
Build and Push Docker Image / build-and-push (push) Successful in 5m7s
- Settings > Preferences: typeface, line spacing, verse numbers, highlight colour names,
  My Studies grid/list and sort, verse-of-the-day Bible and picture, commentary sources, study templates
- Header uses one fixed width and reserves scrollbar space so the nav sits in the same place on every page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:10:12 -04:00
nmemmertandClaude Sonnet 5.5 a2185ba1dd Easier navigation on iPhone
Build and Push Docker Image / build-and-push (push) Successful in 5m7s
- Phones get a bottom tab bar (Studies, Bible, Plans, Prayer, Account); wider screens keep the header nav
- The Account menu opens upward from the bar; toasts sit above it
- Reader and study pages get a labelled Home button instead of a small arrow

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:54:24 -04:00
29 changed files with 836 additions and 35 deletions

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
// Home: how your weekly recalls have gone — the last eight weeks at a glance, then every recall you've written.
import { useMemo, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { loadProjectById } from '../utils/projectStorage.js';
import { recallHistory, recallSummary } from '../utils/recall.js';
const LABEL = { solid: 'Had it', partly: 'Partly', forgot: 'Slipped' };
const DOT = { solid: 'bg-[var(--r-accent)] border-[var(--r-accent)]', partly: 'bg-[var(--r-hl)] border-[var(--r-accent)]', forgot: 'border-[var(--r-sub)]' };
export default function RecallHistory() {
const { projectIndex, openStudyChunk } = useApp();
const [open, setOpen] = useState(false);
const history = useMemo(() => recallHistory(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)), [projectIndex]);
if (history.length === 0) return null;
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">
<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">
{recent.map((w) => (
<span key={w.start} title={w.rating ? LABEL[w.rating] : 'No recall that week'}
className={`h-3 w-3 rounded-full border ${w.rating ? DOT[w.rating] : 'border-[var(--r-line)]'}`} />
))}
<span className="ml-1 text-xs text-[var(--r-sub)]">{open ? '▾' : '▸'}</span>
</span>
</button>
{open && (
<ul className="mt-3 space-y-2.5">
{history.map((h) => (
<li key={`${h.chunkId}-${h.at}`} className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] p-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<span><span className="font-medium text-sm">{h.reference}</span> <span className="text-[var(--r-sub)]">· {h.projectTitle} · {new Date(h.at).toLocaleDateString()}</span></span>
<span className="flex items-center gap-2">
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5">{LABEL[h.rating] ?? '—'}</span>
<button type="button" onClick={() => openStudyChunk(h.projectId, h.chunkId)} className="text-[var(--r-accent)] hover:underline">Open</button>
</span>
</div>
{h.text && <p className="mt-1.5 line-clamp-3 whitespace-pre-line font-serif text-[13px] leading-relaxed text-[var(--r-sub)]">{h.text}</p>}
</li>
))}
</ul>
)}
</section>
);
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function ToastProvider({ children }) {
return ( return (
<ToastContext.Provider value={value}> <ToastContext.Provider value={value}>
{children} {children}
<div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] font-sans"> <div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px)+var(--bottom-nav,0px))] font-sans">
{toasts.map((t) => ( {toasts.map((t) => (
<div key={t.id} role={t.tone === 'error' ? 'alert' : 'status'} <div key={t.id} role={t.tone === 'error' ? 'alert' : 'status'}
className={`pointer-events-auto flex max-w-md items-center gap-3 rounded-full px-4 py-2.5 text-sm shadow-lg ${t.tone === 'error' ? 'bg-[var(--r-danger)] text-[var(--r-bg)]' : 'bg-[var(--r-text)] text-[var(--r-bg)]'}`}> className={`pointer-events-auto flex max-w-md items-center gap-3 rounded-full px-4 py-2.5 text-sm shadow-lg ${t.tone === 'error' ? 'bg-[var(--r-danger)] text-[var(--r-bg)]' : 'bg-[var(--r-text)] text-[var(--r-bg)]'}`}>
+77
View File
@@ -0,0 +1,77 @@
// Home card: once a week, write what you remember of a passage you studied, then compare it with your notes.
import { useMemo, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { loadProjectById } from '../utils/projectStorage.js';
import { pickRecall } from '../utils/recall.js';
const RATE = [
{ id: 'solid', label: 'I had it' },
{ id: 'partly', label: 'Partly' },
{ id: 'forgot', label: 'Most of it slipped' },
];
const box = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
const noteLabels = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
export default function WeeklyRecall() {
const { projectIndex, saveChunkRecall, openStudyChunk } = useApp();
const [step, setStep] = useState('card'); // card -> write -> compare -> done
const [text, setText] = useState('');
const pick = useMemo(
() => pickRecall(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)),
[projectIndex],
);
if (step === 'done') return <div role="status" className={box}>Recall saved. See you next week.</div>;
if (!pick) return null;
const finish = (rating) => {
saveChunkRecall(pick.projectId, pick.chunk.id, { at: Date.now(), text: text.trim(), rating });
setStep('done');
};
if (step === 'card') {
return (
<div className={`${box} flex flex-wrap items-center justify-between gap-3`}>
<span><span className="font-medium">Weekly recall:</span> {pick.reference} <span className="text-[var(--r-sub)]">· {pick.projectTitle}</span></span>
<button type="button" onClick={() => setStep('write')} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)]">Start</button>
</div>
);
}
return (
<section aria-label="Weekly recall" className={box}>
<p className="font-medium">{pick.reference} <span className="font-normal text-[var(--r-sub)]">· {pick.projectTitle}</span></p>
{step === 'write' ? (
<>
<p className="mt-0.5 text-xs text-[var(--r-sub)]">From memory, no peeking: what did this passage say, what did it mean, and what did it ask of you?</p>
<textarea aria-label="What you remember" value={text} onChange={(e) => setText(e.target.value)} rows={6}
className="mt-2 w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 font-serif text-[15px] leading-7 focus:outline-none" />
<div className="mt-2 flex gap-2">
<button type="button" disabled={!text.trim()} onClick={() => setStep('compare')} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] disabled:opacity-40">Show my notes</button>
<button type="button" onClick={() => { setText(''); setStep('card'); }} className="text-xs text-[var(--r-sub)] hover:underline">Not now</button>
</div>
</>
) : (
<>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
<div>
<p className="mb-1 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">What you remembered</p>
<p className="whitespace-pre-line font-serif text-[13px] leading-relaxed">{text}</p>
</div>
<div>
<p className="mb-1 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</p>
{noteLabels.map(([f, label]) => pick.chunk[f]?.trim() && (
<p key={f} className="mb-1.5 whitespace-pre-line font-serif text-[13px] leading-relaxed"><span className="font-sans text-xs font-medium">{label}: </span>{pick.chunk[f]}</p>
))}
</div>
</div>
<p className="mt-3 text-xs text-[var(--r-sub)]">How did you do?</p>
<div className="mt-1 flex flex-wrap gap-2">
{RATE.map((r) => (
<button key={r.id} type="button" onClick={() => finish(r.id)} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs hover:bg-black/5">{r.label}</button>
))}
<button type="button" onClick={() => openStudyChunk(pick.projectId, pick.chunk.id)} className="text-xs text-[var(--r-accent)] hover:underline">Open this chunk to review</button>
</div>
</>
)}
</section>
);
}
+17 -12
View File
@@ -1,26 +1,30 @@
// Home card: today's verse and Spurgeon's Morning & Evening reading, both from IQ Bible. // Home card: today's verse and Spurgeon's Morning & Evening reading, both from IQ Bible.
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useApp } from '../../context/AppContext.js'; import { useApp } from '../../context/AppContext.js';
import { useIq, useIqAvailable } from '../../hooks/useIq.js'; import { useIq, useIqStatus } from '../../hooks/useIq.js';
import VerseShareImage, { BACKGROUND_KEY } from '../VerseShareImage.jsx'; import VerseShareImage, { BACKGROUND_KEY } from '../VerseShareImage.jsx';
import { SCENES, loadPhotoList, resolveBackground, sceneDataUrl } from '../../utils/verseBackgrounds.js'; import { SCENES, loadPhotoList, resolveBackground, sceneDataUrl } from '../../utils/verseBackgrounds.js';
import { bookOptions } from '../../utils/bibleUtils.jsx'; import { bookOptions } from '../../utils/bibleUtils.jsx';
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js'; import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
import { fallbackVerseFor } from '../../utils/fallbackVerses.js';
export default function IqDailyCard() { export default function IqDailyCard() {
const on = useIqAvailable(); const status = useIqStatus();
if (!on) return null; if (status === null) return null;
return <Card />; return <Card iq={status} />;
} }
function Card() { function Card({ iq }) {
const { jumpToReaderVerse, openBibleReader, readerTranslation, readerTheme } = useApp(); const { jumpToReaderVerse, openBibleReader, readerTranslation, readerTheme } = useApp();
// Today's verse is chosen by IQ Bible (the same for everyone); its words come from the Bible you pick. // Today's verse is chosen by IQ Bible (the same for everyone); its words come from the Bible you pick.
const votd = useIq('bibles/eng_kjv/verse-of-the-day'); const votd = useIq(iq ? 'bibles/eng_kjv/verse-of-the-day' : null);
// When IQ Bible can't give us a verse (down, no key, allowance used up) the card shows a built-in one in the
// reader's own translation instead of disappearing.
const fallback = !votd.data?.data && votd.status !== 'loading';
const [chosen, setChosen] = useState(() => { try { return localStorage.getItem('votd-translation') || ''; } catch { return ''; } }); const [chosen, setChosen] = useState(() => { try { return localStorage.getItem('votd-translation') || ''; } catch { return ''; } });
const translation = READER_TRANSLATIONS.some((t) => t.id === chosen) ? chosen : (readerTranslation || 'BSB'); const translation = !fallback && READER_TRANSLATIONS.some((t) => t.id === chosen) ? chosen : (readerTranslation || 'BSB');
const pick = (id) => { setChosen(id); try { localStorage.setItem('votd-translation', id); } catch { /* storage unavailable */ } }; const pick = (id) => { setChosen(id); try { localStorage.setItem('votd-translation', id); } catch { /* storage unavailable */ } };
const ref = votd.data?.data; const ref = votd.data?.data ?? (fallback ? fallbackVerseFor() : undefined);
const [text, setText] = useState({ key: '', value: '', error: false }); const [text, setText] = useState({ key: '', value: '', error: false });
const [sharing, setSharing] = useState(false); const [sharing, setSharing] = useState(false);
// The picture behind the verse: a different one each day by default, or the one you chose. // The picture behind the verse: a different one each day by default, or the one you chose.
@@ -42,15 +46,16 @@ function Card() {
.catch(() => { if (!controller.signal.aborted) setText({ key, value: '', error: true }); }); .catch(() => { if (!controller.signal.aborted) setText({ key, value: '', error: true }); });
return () => controller.abort(); return () => controller.abort();
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps }, [key]); // eslint-disable-line react-hooks/exhaustive-deps
const dev = useIq('devotionals/today'); const dev = useIq(iq ? 'devotionals/today' : null);
const [part, setPart] = useState(() => (new Date().getHours() < 15 ? 'morning' : 'evening')); const [part, setPart] = useState(() => (new Date().getHours() < 15 ? 'morning' : 'evening'));
const verse = votd.data?.data; const verse = ref;
const reading = dev.data?.data?.[part]; const reading = dev.data?.data?.[part];
const loaded = text.key === key; const loaded = text.key === key;
const shown = loaded && text.value ? text.value : loaded && text.error ? ref?.text ?? '' : '…'; const shown = loaded && text.value ? text.value : loaded && text.error ? ref?.text ?? '' : '…';
const shownTranslation = loaded && text.error ? 'KJV' : translation; const shownTranslation = loaded && text.error ? 'KJV' : translation;
const name = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev; const name = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
if (!verse && !reading) return null; if (!verse && !reading) return null;
if (fallback && !reading && loaded && text.error) return null; // nothing to show, not even the built-in verse
return ( return (
<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 && (
@@ -65,10 +70,10 @@ function Card() {
<optgroup label="Scenes">{SCENES.map((sc) => <option key={sc.id} value={sc.id}>{sc.name}</option>)}</optgroup> <optgroup label="Scenes">{SCENES.map((sc) => <option key={sc.id} value={sc.id}>{sc.name}</option>)}</optgroup>
{photos.length > 0 && <optgroup label="Your photos">{photos.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</optgroup>} {photos.length > 0 && <optgroup label="Your photos">{photos.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</optgroup>}
</select> </select>
<select aria-label="Verse of the day translation" value={translation} onChange={(e) => pick(e.target.value)} {!fallback && <select aria-label="Verse of the day translation" value={translation} onChange={(e) => pick(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">
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id}</option>)} {READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id}</option>)}
</select> </select>}
</span> </span>
</div> </div>
{/* the translation's text; the KJV from IQ Bible is the fallback if that Bible can't be loaded right now */} {/* the translation's text; the KJV from IQ Bible is the fallback if that Bible can't be loaded right now */}
+14
View File
@@ -160,6 +160,20 @@ describe('IqDailyCard', () => {
}); });
}); });
describe('IqDailyCard fallback', () => {
test('when IQ Bible is down, shows the built-in verse of the day in the reader translation, with no picker', async () => {
const { default: IqDailyCard } = await import('./IqDailyCard.jsx');
const { fallbackVerseFor } = await import('../../utils/fallbackVerses.js');
const v = fallbackVerseFor();
routes['/api/iq/bibles/eng_kjv/verse-of-the-day'] = { status: 502, body: { error: 'down' } };
routes[`https://bible.helloao.org/api/BSB/${v.book_usfm}/${v.chapter_number}.json`] = { chapter: { content: [{ type: 'verse', number: v.verse_number, content: ['Built-in verse text'] }] } };
const { AppContext } = await import('../../context/AppContext.js');
render(<AppContext.Provider value={{ jumpToReaderVerse: vi.fn(), openBibleReader: vi.fn(), readerTranslation: 'BSB' }}><IqDailyCard /></AppContext.Provider>);
expect(await screen.findByText('Built-in verse text')).toBeInTheDocument();
expect(screen.queryByLabelText('Verse of the day translation')).toBeNull();
});
});
describe('Study side', () => { describe('Study side', () => {
const chunk = { id: 'c1', startVerse: 14, endVerse: 16, tags: ['love'], generalNotes: '' }; const chunk = { id: 'c1', startVerse: 14, endVerse: 16, tags: ['love'], generalNotes: '' };
const chapter = { book: 'John', bookAbbrev: 'JHN', chapter: '3' }; const chapter = { book: 'John', bookAbbrev: 'JHN', chapter: '3' };
+11
View File
@@ -0,0 +1,11 @@
import { HomeIcon } from './icons.jsx';
// The way back to the dashboard from the Reader and from a study. Always labelled, and big enough to hit with a thumb.
export default function HomeButton({ onClick }) {
return (
<button type="button" onClick={onClick} aria-label="Back to home" data-tip="Back to home"
className="flex h-10 shrink-0 items-center gap-1.5 rounded-full border border-[var(--r-line)] px-3 text-sm text-[var(--r-text)] transition hover:bg-black/5">
<HomeIcon /><span>Home</span>
</button>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
// Top and bottom chrome for the reader. Everything stays one tap away; focus // Top and bottom chrome for the reader. Everything stays one tap away; focus
// mode hides both. // mode hides both.
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon, HighlighterIcon } from './icons.jsx'; import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon, HighlighterIcon } from './icons.jsx';
import HomeButton from './HomeButton.jsx';
import { BookmarkIcon } from '../../utils/bibleUtils.jsx'; import { BookmarkIcon } from '../../utils/bibleUtils.jsx';
const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent'; const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent';
@@ -9,7 +10,7 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
return ( return (
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans"> <div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2"> <div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
<button type="button" onClick={onBack} className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] transition hover:bg-black/5" aria-label="Back to home" data-tip="Back to home"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button> <HomeButton onClick={onBack} />
<button type="button" onClick={onOpenNavigator} <button type="button" onClick={onOpenNavigator}
className="mx-auto flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--r-text)] hover:bg-black/5" data-tip="Go to a book and chapter (G)"> className="mx-auto flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--r-text)] hover:bg-black/5" data-tip="Go to a book and chapter (G)">
<span className="truncate">{title}</span><ChevronDown /> <span className="truncate">{title}</span><ChevronDown />
+1
View File
@@ -2,6 +2,7 @@
const base = { viewBox: '0 0 20 20', width: 20, height: 20, fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }; const base = { viewBox: '0 0 20 20', width: 20, height: 20, fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true };
export const ChevronLeft = () => <svg {...base}><path d="M12 4l-6 6 6 6" /></svg>; export const ChevronLeft = () => <svg {...base}><path d="M12 4l-6 6 6 6" /></svg>;
export const HomeIcon = () => <svg {...base}><path d="M3.5 9.5L10 4l6.5 5.5M5 8.5V16h3.5v-4h3v4H15V8.5" /></svg>;
export const ChevronRight = () => <svg {...base}><path d="M8 4l6 6-6 6" /></svg>; export const ChevronRight = () => <svg {...base}><path d="M8 4l6 6-6 6" /></svg>;
export const ChevronDown = ({ size = 14 }) => <svg {...base} width={size} height={size}><path d="M5 8l5 5 5-5" /></svg>; export const ChevronDown = ({ size = 14 }) => <svg {...base} width={size} height={size}><path d="M5 8l5 5 5-5" /></svg>;
export const SearchIcon = () => <svg {...base}><circle cx="9" cy="9" r="5.5" /><path d="M13.5 13.5L17 17" /></svg>; export const SearchIcon = () => <svg {...base}><circle cx="9" cy="9" r="5.5" /><path d="M13.5 13.5L17 17" /></svg>;
+121
View File
@@ -0,0 +1,121 @@
// Settings → Preferences: the choices that used to be reachable only from the screen they affect
// (highlight colour names, the dashboard's layout, the verse of the day, saved study templates).
import { useEffect, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { useLocalSetting } from '../../hooks/useLocalSetting.js';
import { useIqAvailable } from '../../hooks/useIq.js';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS } from '../../utils/highlightColors.js';
import { SCENES, loadPhotoList } from '../../utils/verseBackgrounds.js';
import { BACKGROUND_KEY } from '../VerseShareImage.jsx';
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6';
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)]';
function Row({ label, hint, children }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 border-t border-[var(--r-line)] py-3 first:border-t-0">
<div className="min-w-0 flex-1 basis-48">
<p className="text-sm">{label}</p>
{hint && <p className="text-xs text-[var(--r-sub)]">{hint}</p>}
</div>
{children}
</div>
);
}
export function HighlightColours() {
const { readerColorLabels = {}, setReaderColorLabel } = useApp();
return (
<section aria-label="Highlight colours" className={card}>
<h2 className="text-base font-medium">Highlight colours</h2>
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Give each colour a meaning, such as “Promise” or “Command”. The names show in your Highlights list and exports, and sync to your other devices.</p>
{HIGHLIGHT_COLORS.map((c) => (
<div key={c} className="flex items-center gap-3 border-t border-[var(--r-line)] py-2 first:border-t-0">
<span aria-hidden="true" className="h-5 w-5 shrink-0 rounded-full border border-black/10" style={{ background: c }} />
<input aria-label={`Name for ${COLOR_NAMES[c].toLowerCase()} highlights`} value={readerColorLabels[c] ?? ''} maxLength={30}
onChange={(e) => setReaderColorLabel(c, e.target.value)} placeholder={COLOR_SUGGESTIONS[c] ?? COLOR_NAMES[c]} className={`${field} min-w-0 flex-1`} />
</div>
))}
</section>
);
}
export function DashboardSettings() {
const [view, setView] = useLocalSetting('homeView', 'grid');
const [sort, setSort] = useLocalSetting('homeSort', 'recent');
return (
<section aria-label="My Studies page" className={card}>
<h2 className="text-base font-medium">My Studies page</h2>
<Row label="Show studies as" hint="You can still switch on the page itself.">
<div role="group" aria-label="Show studies as" className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{[['grid', 'Grid'], ['list', 'List']].map(([id, text]) => (
<button key={id} type="button" aria-pressed={view === id} onClick={() => setView(id)}
className={`px-3 py-2 text-sm ${view === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{text}</button>
))}
</div>
</Row>
<Row label="Sort by">
<select aria-label="Sort studies by" value={sort} onChange={(e) => setSort(e.target.value)} className={field}>
<option value="recent">Last edited</option>
<option value="title">Title</option>
<option value="passage">Passage</option>
</select>
</Row>
</section>
);
}
export function VerseOfDaySettings() {
const on = useIqAvailable();
const { readerTranslation } = useApp();
const [translation, setTranslation] = useLocalSetting('votd-translation', '');
const [background, setBackground] = useLocalSetting(BACKGROUND_KEY, 'auto');
const [photos, setPhotos] = useState([]);
useEffect(() => { let live = true; loadPhotoList().then((list) => { if (live) setPhotos(list); }); return () => { live = false; }; }, []);
if (!on) return null;
const shown = READER_TRANSLATIONS.some((t) => t.id === translation) ? translation : '';
return (
<section aria-label="Verse of the day" className={card}>
<h2 className="text-base font-medium">Verse of the day</h2>
<p className="mb-1 mt-1 text-sm text-[var(--r-sub)]">The card on your My Studies page. Turn on the daily reminder under Notifications.</p>
<Row label="Bible" hint="Today’s verse is chosen for everyone; its words come from the Bible you pick.">
<select aria-label="Verse of the day Bible" value={shown} onChange={(e) => setTranslation(e.target.value)} className={field}>
<option value="">Same as my default Bible ({readerTranslation})</option>
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
</select>
</Row>
<Row label="Picture behind the verse">
<select aria-label="Verse of the day picture" value={background} onChange={(e) => setBackground(e.target.value)} className={field}>
<option value="auto">A different picture each day</option>
<option value="plain">Plain</option>
<optgroup label="Scenes">{SCENES.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</optgroup>
{photos.length > 0 && <optgroup label="Your photos">{photos.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</optgroup>}
</select>
</Row>
</section>
);
}
export function StudyTemplates() {
const { userTemplates = [], deleteUserTemplate } = useApp();
return (
<section aria-label="Study templates" className={card}>
<h2 className="text-base font-medium">Study templates</h2>
<p className="mb-2 mt-1 text-sm text-[var(--r-sub)]">Templates you saved from a study’s Setup stage. They sync to your other devices.</p>
{userTemplates.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">None saved yet.</p>
) : (
<ul className="divide-y divide-[var(--r-line)]">
{userTemplates.map((t) => (
<li key={t.id} className="flex items-center justify-between gap-3 py-2">
<span className="min-w-0"><span className="text-sm">{t.name}</span>{t.description && <span className="block truncate text-xs text-[var(--r-sub)]">{t.description}</span>}</span>
<button type="button" onClick={() => { if (window.confirm(`Delete the template “${t.name}”? Studies already made from it keep their notes.`)) deleteUserTemplate(t.id); }}
className="shrink-0 rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Delete</button>
</li>
))}
</ul>
)}
</section>
);
}
+26 -2
View File
@@ -2,6 +2,7 @@
// area for background notes and tags so the page stays quiet. // area for background notes and tags so the page stays quiet.
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import DictateButton, { appendSpoken } from '../DictateButton.jsx'; import DictateButton, { appendSpoken } from '../DictateButton.jsx';
import { ownShare, ownWords, pastedWords, withPaste, clearPastes } from '../../utils/ownWork.js';
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js'; import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
const SECTIONS = [ const SECTIONS = [
@@ -36,11 +37,23 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
}; };
const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase())); const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase()));
const share = ownShare(chunk);
const keepPaste = (field) => (e) => {
const next = withPaste(chunk, field, e.clipboardData?.getData('text'));
if (next) updateChunk(chunk.id, { aiPastes: next });
};
return ( return (
<section aria-label="Study notes" className="space-y-2"> <section aria-label="Study notes" className="space-y-2">
{share !== null && (
<p className="px-1 text-xs text-[var(--r-sub)]" title="Long pastes into a note box count as pasted, not your own">
{share === 100 ? 'All of these notes are your own words.' : `${share}% of these notes are your own words.`}
</p>
)}
{SECTIONS.map(({ field, label, hint, placeholder }) => { {SECTIONS.map(({ field, label, hint, placeholder }) => {
const isOpen = open === field; const isOpen = open === field;
const n = words(chunk[field]); const n = words(chunk[field]);
const pasted = pastedWords(chunk, field);
return ( return (
<div key={field} className={`overflow-hidden rounded-2xl border ${isOpen ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}> <div key={field} className={`overflow-hidden rounded-2xl border ${isOpen ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
<button type="button" onClick={() => setOpen(isOpen ? null : field)} aria-expanded={isOpen} <button type="button" onClick={() => setOpen(isOpen ? null : field)} aria-expanded={isOpen}
@@ -50,12 +63,23 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
<span className="text-sm font-medium">{label}</span> <span className="text-sm font-medium">{label}</span>
<span className="text-xs text-[var(--r-sub)]">{hint}</span> <span className="text-xs text-[var(--r-sub)]">{hint}</span>
</span> </span>
<span className="text-xs text-[var(--r-sub)]">{n ? `${n} word${n === 1 ? '' : 's'}` : ''}</span> <span className="flex items-center gap-2 text-xs text-[var(--r-sub)]">
{n > 0 && (pasted > 0
? <span className="rounded-full bg-[var(--r-hl)] px-2 py-0.5 text-[var(--r-text)]">{ownWords(chunk, field) === 0 ? 'Pasted' : `${pasted} pasted`}</span>
: <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5">Mine</span>)}
{n ? `${n} word${n === 1 ? '' : 's'}` : ''}
</span>
</button> </button>
{isOpen && ( {isOpen && (
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder} <textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} /> onPaste={keepPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
{pasted > 0 && (
<p className="mt-1.5 text-xs text-[var(--r-sub)]">
{pasted} word{pasted === 1 ? '' : 's'} here were pasted, so they don't count as your own work.{' '}
<button type="button" onClick={() => updateChunk(chunk.id, { aiPastes: clearPastes(chunk, field) })} className="text-[var(--r-accent)] hover:underline">That’s mine</button>
</p>
)}
<div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div> <div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
</div> </div>
)} )}
+51 -10
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js'; import { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js'; import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft, SearchIcon } from '../reader/icons.jsx'; import { ChevronLeft, SearchIcon } from '../reader/icons.jsx';
import { useIsPhone } from '../../hooks/useIsPhone.js';
// `short` is what a phone shows; the full label is still the button's accessible name. // `short` is what a phone shows; the full label is still the button's accessible name.
const NAV = [ const NAV = [
@@ -13,8 +14,18 @@ const NAV = [
{ id: 'prayer', label: 'Prayer', short: 'Prayer' }, { id: 'prayer', label: 'Prayer', short: 'Prayer' },
]; ];
const ICONS = {
studies: 'M4 5.5A1.5 1.5 0 0 1 5.5 4H18a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H6a2 2 0 0 1-2-2V5.5ZM8 4v15M11 8h5M11 11.5h5',
reader: 'M12 6.5C10.5 5 8 4.5 4.5 4.5V18c3.5 0 6 .5 7.5 2 1.5-1.5 4-2 7.5-2V4.5c-3.5 0-6 .5-7.5 2ZM12 6.5V20',
plans: 'M5 6.5h14M5 6.5v12A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5v-12M5 6.5A1.5 1.5 0 0 1 6.5 5H8m11 1.5A1.5 1.5 0 0 0 17.5 5H16M8 3.5v3m8-3v3M9 12.5l2 2 4-4',
prayer: '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',
};
const TabIcon = ({ id }) => (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={ICONS[id]} /></svg>
);
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out. // Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
function AccountMenu() { function AccountMenu({ tab = false, active = false }) {
const { authUser, readerTheme, setReaderTheme, setCurrentPage, logout } = useApp(); const { authUser, readerTheme, setReaderTheme, setCurrentPage, logout } = useApp();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef(null); const ref = useRef(null);
@@ -33,12 +44,20 @@ function AccountMenu() {
return ( return (
<div ref={ref} className="relative"> <div ref={ref} className="relative">
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open} {tab ? (
className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-xs font-medium uppercase hover:bg-black/5"> <button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
{authUser?.email?.[0] ?? '·'} className={`flex min-h-[52px] w-full flex-col items-center justify-center gap-0.5 text-[11px] ${open || active ? 'text-[var(--r-text)]' : 'text-[var(--r-sub)]'}`}>
</button> <span className="flex h-[22px] w-[22px] items-center justify-center rounded-full border border-current text-[11px] font-medium uppercase">{authUser?.email?.[0] ?? '·'}</span>
<span aria-hidden="true">Account</span>
</button>
) : (
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-xs font-medium uppercase hover:bg-black/5">
{authUser?.email?.[0] ?? '·'}
</button>
)}
{open && ( {open && (
<div role="menu" className="absolute right-0 top-full z-40 mt-2 w-60 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-2 shadow-xl"> <div role="menu" className={`absolute z-40 w-60 ${tab ? 'bottom-full right-2 mb-2' : 'right-0 top-full mt-2'} rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-2 shadow-xl`}>
{authUser && <p className="truncate px-3 pb-1 pt-2 text-xs text-[var(--r-sub)]">{authUser.email}</p>} {authUser && <p className="truncate px-3 pb-1 pt-2 text-xs text-[var(--r-sub)]">{authUser.email}</p>}
<div className="px-3 pb-2 pt-1"> <div className="px-3 pb-2 pt-1">
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Theme</p> <p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Theme</p>
@@ -65,23 +84,30 @@ function AccountMenu() {
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) { export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPrayer, openPalette } = useApp(); const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPrayer, openPalette } = useApp();
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans, prayer: openPrayer }; const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans, prayer: openPrayer };
const phone = useIsPhone();
// On a phone the destinations sit in a bar at the bottom, where a thumb reaches; toasts lift above it.
useEffect(() => {
if (!phone) return undefined;
document.documentElement.style.setProperty('--bottom-nav', '56px');
return () => document.documentElement.style.removeProperty('--bottom-nav');
}, [phone]);
return ( return (
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}> <div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<a href="#content" className="skip-link">Skip to content</a> <a href="#content" className="skip-link">Skip to content</a>
<header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]"> <header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-2 px-3 sm:gap-4 sm:px-4`}> <div className="mx-auto flex h-12 max-w-6xl items-center gap-2 px-3 sm:gap-4 sm:px-4">
<button type="button" onClick={goHome} className="-m-2 flex shrink-0 items-center gap-2 p-2" aria-label="Bible Study home"> <button type="button" onClick={goHome} className="-m-2 flex shrink-0 items-center gap-2 p-2" aria-label="Bible Study home">
<img src="/logo.svg" alt="" className="h-6 w-6" /> <img src="/logo.svg" alt="" className="h-6 w-6" />
<span className="hidden text-sm font-medium sm:inline">Bible Study</span> <span className="hidden text-sm font-medium sm:inline">Bible Study</span>
</button> </button>
<nav aria-label="Main" className="flex items-center gap-0.5"> {!phone && <nav aria-label="Main" className="flex items-center gap-0.5">
{NAV.map((n) => ( {NAV.map((n) => (
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label} <button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
className={`whitespace-nowrap rounded-full px-2.5 py-1.5 text-sm sm:px-3 ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}> className={`whitespace-nowrap rounded-full px-2.5 py-1.5 text-sm sm:px-3 ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
<span className="sm:hidden" aria-hidden="true">{n.short}</span><span className="hidden sm:inline" aria-hidden="true">{n.label}</span> <span className="sm:hidden" aria-hidden="true">{n.short}</span><span className="hidden sm:inline" aria-hidden="true">{n.label}</span>
</button> </button>
))} ))}
</nav> </nav>}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{openPalette && ( {openPalette && (
<button type="button" onClick={openPalette} aria-label="Search and go to" data-tip="Search studies, notes and passages (⌘K)" <button type="button" onClick={openPalette} aria-label="Search and go to" data-tip="Search studies, notes and passages (⌘K)"
@@ -89,7 +115,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
<SearchIcon /><span className="hidden sm:inline">Search</span><kbd className="hidden rounded border border-[var(--r-line)] px-1 text-[10px] md:inline">⌘K</kbd> <SearchIcon /><span className="hidden sm:inline">Search</span><kbd className="hidden rounded border border-[var(--r-line)] px-1 text-[10px] md:inline">⌘K</kbd>
</button> </button>
)} )}
<AccountMenu /> {!phone && <AccountMenu />}
</div> </div>
</div> </div>
</header> </header>
@@ -103,6 +129,21 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
</div> </div>
{children} {children}
</div> </div>
{phone && (
<>
<div aria-hidden="true" className="h-[calc(5rem+env(safe-area-inset-bottom,0px))]" />
<nav aria-label="Main" className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)]">
{NAV.map((n) => (
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
className={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 text-[11px] ${active === n.id ? 'text-[var(--r-text)]' : 'text-[var(--r-sub)]'}`}>
<TabIcon id={n.id} />
<span aria-hidden="true" className={active === n.id ? 'font-medium' : ''}>{n.short}</span>
</button>
))}
<AccountMenu tab active={['settings', 'admin'].includes(active)} />
</nav>
</>
)}
</div> </div>
); );
} }
@@ -0,0 +1,46 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const app = {
readerTheme: 'paper', authUser: { email: 'sam@x.test' }, setReaderTheme: vi.fn(), setCurrentPage: vi.fn(), logout: vi.fn(),
goHome: vi.fn(), goStudies: vi.fn(), openBibleReader: vi.fn(), openReadingPlans: vi.fn(), openPrayer: vi.fn(), openPalette: vi.fn(),
};
vi.mock('../../context/AppContext.js', () => ({ useApp: () => app }));
import PageShell from './PageShell.jsx';
const phone = (on) => vi.stubGlobal('matchMedia', (q) => ({ matches: on && q.includes('639'), media: q, addEventListener() {}, removeEventListener() {} }));
beforeEach(() => Object.values(app).forEach((f) => f.mockClear?.()));
afterEach(() => vi.unstubAllGlobals());
test('on a phone the destinations are a bar at the bottom, with Account in it', async () => {
phone(true);
const user = userEvent.setup();
render(<PageShell title="Prayer journal" active="prayer"><p>content</p></PageShell>);
const nav = screen.getByRole('navigation', { name: 'Main' });
expect(within(nav).getAllByRole('button').map((b) => b.getAttribute('aria-label'))).toEqual(['Studies', 'Read Bible', 'Reading Plans', 'Prayer', 'Account menu']);
expect(within(nav).getByRole('button', { name: 'Prayer' })).toHaveAttribute('aria-current', 'page');
expect(screen.getAllByRole('button', { name: 'Account menu' })).toHaveLength(1); // not also in the header
await user.click(within(nav).getByRole('button', { name: 'Reading Plans' }));
expect(app.openReadingPlans).toHaveBeenCalled();
await user.click(within(nav).getByRole('button', { name: 'Account menu' }));
await user.click(screen.getByRole('menuitem', { name: 'Settings' }));
expect(app.setCurrentPage).toHaveBeenCalledWith('settings');
});
test('on a wide screen the destinations stay in the header', () => {
phone(false);
render(<PageShell title="Prayer journal" active="prayer"><p>content</p></PageShell>);
const nav = screen.getByRole('navigation', { name: 'Main' });
expect(nav.closest('header')).not.toBeNull();
expect(screen.getAllByRole('button', { name: 'Account menu' })).toHaveLength(1);
});
test('the header is the same width on every page, whatever width the page content is', () => {
phone(false);
const { container, rerender } = render(<PageShell title="A" maxWidth="max-w-3xl"><p>x</p></PageShell>);
const inner = () => container.querySelector('header > div');
expect(inner().className).toContain('max-w-6xl');
rerender(<PageShell title="B" maxWidth="max-w-2xl"><p>x</p></PageShell>);
expect(inner().className).toContain('max-w-6xl');
});
+2 -3
View File
@@ -3,7 +3,7 @@
// the stage switcher, and save/sync status. // the stage switcher, and save/sync status.
import { useApp } from '../../context/AppContext.js'; import { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js'; import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft } from '../reader/icons.jsx'; import HomeButton from '../reader/HomeButton.jsx';
export const STAGES = [ export const STAGES = [
{ id: 'setup', label: 'Chunks' }, { id: 'setup', label: 'Chunks' },
@@ -27,8 +27,7 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
<div className={`bg-[var(--r-bg)] font-sans text-[var(--r-text)] ${fixedHeight ? 'fixed inset-0 flex flex-col overflow-hidden' : 'min-h-dvh'}`} style={themeVars(readerTheme)}> <div className={`bg-[var(--r-bg)] font-sans text-[var(--r-text)] ${fixedHeight ? 'fixed inset-0 flex flex-col overflow-hidden' : 'min-h-dvh'}`} style={themeVars(readerTheme)}>
<div className="safe-top z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]"> <div className="safe-top z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3"> <div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3">
<button type="button" onClick={goHome} aria-label="Back to home" <HomeButton onClick={goHome} />
className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
<div className="min-w-0 max-w-[16rem] truncate text-sm font-medium">{project?.title ?? ''}</div> <div className="min-w-0 max-w-[16rem] truncate text-sm font-medium">{project?.title ?? ''}</div>
<nav aria-label="Study stages" className="mx-auto flex gap-0.5"> <nav aria-label="Study stages" className="mx-auto flex gap-0.5">
{STAGES.map((s) => { {STAGES.map((s) => {
+6 -1
View File
@@ -3,7 +3,12 @@ import { iqAvailable, iqGet } from '../utils/iqbible.js';
// Whether the server has an IQ Bible key. false until known, so IQ-only UI never flashes up. // Whether the server has an IQ Bible key. false until known, so IQ-only UI never flashes up.
export function useIqAvailable() { export function useIqAvailable() {
const [on, setOn] = useState(false); return useIqStatus() === true;
}
// Same, but null until the server has answered.
export function useIqStatus() {
const [on, setOn] = useState(null);
useEffect(() => { useEffect(() => {
let live = true; let live = true;
iqAvailable().then((v) => { if (live) setOn(v); }); iqAvailable().then((v) => { if (live) setOn(v); });
+19
View File
@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
const QUERY = '(max-width: 639px)';
const read = () => (typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(QUERY).matches : false);
// True on a phone-width screen (under Tailwind's `sm`). False where matchMedia is missing (tests), so the full
// layout is what renders there.
export function useIsPhone() {
const [phone, setPhone] = useState(read);
useEffect(() => {
if (typeof window.matchMedia !== 'function') return undefined;
const mq = window.matchMedia(QUERY);
const update = () => setPhone(mq.matches);
update();
mq.addEventListener?.('change', update);
return () => mq.removeEventListener?.('change', update);
}, []);
return phone;
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
const EVENT = 'local-setting';
// A preference kept in this browser's localStorage, for settings that live in more than one place (the Settings
// page and the screen they affect). Changing it in one place updates every component that reads it.
export function useLocalSetting(key, fallback) {
const read = () => { try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; } };
const [value, setValue] = useState(read);
useEffect(() => {
const on = (e) => { if (!e.detail || e.detail.key === key) setValue(read()); };
window.addEventListener(EVENT, on);
window.addEventListener('storage', on);
return () => { window.removeEventListener(EVENT, on); window.removeEventListener('storage', on); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const set = (next) => {
try { localStorage.setItem(key, next); } catch { /* storage unavailable: still applies until reload */ }
setValue(next);
window.dispatchEvent(new CustomEvent(EVENT, { detail: { key } }));
};
return [value, set];
}
+27
View File
@@ -221,6 +221,32 @@ export function useProjectLibrary({
}); });
}; };
// Saves a weekly recall (what you remembered, and how it went) on one chunk of a stored study.
const saveChunkRecall = (id, chunkId, recall) => {
const loaded = loadProjectById(id);
if (!loaded) return;
const updated = {
...loaded,
lastEdited: Date.now(),
chapters: loaded.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, recalls: [...(c.recalls ?? []), recall] } : c)),
})),
};
saveProjectToStorage(updated);
unsyncedStudies.add(id);
setProjectIndex(loadProjectIndex());
if (project?.id === id) setProject(updated);
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
if (result.ok) {
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
setSyncBase(id, serverEdited);
unsyncedStudies.remove(id);
try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ }
} else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
});
};
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy // Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
// removed (and straight away if the tab is closed first, so a deleted project never comes back). // removed (and straight away if the tab is closed first, so a deleted project never comes back).
const finishDelete = (id) => { const finishDelete = (id) => {
@@ -287,6 +313,7 @@ export function useProjectLibrary({
openStudyChunk, openStudyChunk,
renameProjectInStorage, renameProjectInStorage,
setProjectSeries, setProjectSeries,
saveChunkRecall,
deleteProject, deleteProject,
pullLatestFromServer, pullLatestFromServer,
}; };
+1
View File
@@ -34,6 +34,7 @@ select {
/* iPhone and iPad: don't inflate text when the phone is turned sideways, no grey flash on tap, and no /* iPhone and iPad: don't inflate text when the phone is turned sideways, no grey flash on tap, and no
double-tap-to-zoom delay on controls. */ double-tap-to-zoom delay on controls. */
html { html {
scrollbar-gutter: stable; /* pages with and without a scrollbar line up the same */
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
text-size-adjust: 100%; text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
+1 -1
View File
@@ -70,7 +70,7 @@ export default function AdminPage() {
}; };
return ( return (
<PageShell title="Admin" onBack={goHome} maxWidth="max-w-5xl"> <PageShell title="Admin" active="admin" onBack={goHome} maxWidth="max-w-5xl">
<main className="mx-auto max-w-5xl space-y-5 px-4 py-6"> <main className="mx-auto max-w-5xl space-y-5 px-4 py-6">
<div className="flex gap-2"> <div className="flex gap-2">
<button type="button" onClick={() => setAdminTab('users')} <button type="button" onClick={() => setAdminTab('users')}
+4
View File
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js'; 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 WeeklyRecall from '../components/WeeklyRecall.jsx';
import RecallHistory from '../components/RecallHistory.jsx';
import IqDailyCard from '../components/iq/IqDailyCard.jsx'; import IqDailyCard from '../components/iq/IqDailyCard.jsx';
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx'; import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
import PageShell from '../components/study/PageShell.jsx'; import PageShell from '../components/study/PageShell.jsx';
@@ -135,6 +137,8 @@ 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-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
<section className="min-w-0"> <section className="min-w-0">
<WeeklyRecall />
<RecallHistory />
<IqDailyCard /> <IqDailyCard />
{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>
+29 -3
View File
@@ -11,6 +11,7 @@ import { useToast } from '../components/Toasts.jsx';
import StudioShell from '../components/study/StudioShell.jsx'; import StudioShell from '../components/study/StudioShell.jsx';
import { formatChunkReference } from '../utils/bibleUtils.jsx'; import { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js'; import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
import { chunksNeedingWork } from '../utils/ownWork.js';
import { buildScriptDraft, spokenMinutes, wordCount } from '../utils/scriptDraft.js'; import { buildScriptDraft, spokenMinutes, wordCount } from '../utils/scriptDraft.js';
const has = (s) => (s ?? '').trim().length > 0; const has = (s) => (s ?? '').trim().length > 0;
@@ -23,6 +24,7 @@ export default function ScriptPage() {
const iq = useIqAvailable(); const iq = useIqAvailable();
const [gathering, setGathering] = useState(false); const [gathering, setGathering] = useState(false);
const [presenting, setPresenting] = useState(false); const [presenting, setPresenting] = useState(false);
const [heldAction, setHeldAction] = useState(null); // an AI hand-off waiting on the own-work check
const { toast } = useToast(); const { toast } = useToast();
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null; const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
@@ -54,6 +56,11 @@ export default function ScriptPage() {
} }
}; };
// AI hand-offs only go straight through once every chunk has your own notes; otherwise ask first.
const needingWork = chunksNeedingWork(allChunks);
const guarded = (action) => () => { if (needingWork.length === 0) action(); else setHeldAction(() => action); };
const goStudy = (id) => { select(id); setHeldAction(null); setCurrentPage('study'); };
// Copies a prompt, then opens claude.ai so it can be pasted straight in. // Copies a prompt, then opens claude.ai so it can be pasted straight in.
const copyPrompt = (prompt) => { const copyPrompt = (prompt) => {
navigator.clipboard.writeText(prompt).then(() => { navigator.clipboard.writeText(prompt).then(() => {
@@ -130,12 +137,12 @@ export default function ScriptPage() {
<input type="file" accept=".docx" className="hidden" onChange={(e) => { importDocx(e.target.files?.[0]); e.target.value = ''; }} /> <input type="file" accept=".docx" className="hidden" onChange={(e) => { importDocx(e.target.files?.[0]); e.target.value = ''; }} />
</label> </label>
<span className="mx-1 h-4 w-px bg-[var(--r-line)]" /> <span className="mx-1 h-4 w-px bg-[var(--r-line)]" />
<button type="button" onClick={() => copyPrompt(buildClaudePrompt(project))} className={btn} <button type="button" onClick={guarded(() => copyPrompt(buildClaudePrompt(project)))} className={btn}
title="Copies your study notes as a Claude prompt, then opens claude.ai">Study guide → Claude ↗</button> title="Copies your study notes as a Claude prompt, then opens claude.ai">Study guide → Claude ↗</button>
<button type="button" onClick={() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName))} className={btn} <button type="button" onClick={guarded(() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName)))} className={btn}
title="Copies a podcast script prompt for Claude — set your show name in Account Settings first">Podcast → Claude ↗</button> title="Copies a podcast script prompt for Claude — set your show name in Account Settings first">Podcast → Claude ↗</button>
{iq && ( {iq && (
<button type="button" disabled={gathering} onClick={copyPodcastWithContext} className={btn} <button type="button" disabled={gathering} onClick={guarded(copyPodcastWithContext)} className={btn}
title={`Adds IQ Bible commentary, cross-references and word glosses for up to ${chunkRefs(project).refs.length} passages (each uses a few API requests)`}> title={`Adds IQ Bible commentary, cross-references and word glosses for up to ${chunkRefs(project).refs.length} passages (each uses a few API requests)`}>
{gathering ? 'Gathering…' : 'Podcast + background → Claude ↗'} {gathering ? 'Gathering…' : 'Podcast + background → Claude ↗'}
</button> </button>
@@ -145,6 +152,25 @@ export default function ScriptPage() {
<DictateButton label="Dictate script" onText={(said) => set({ finalScript: appendSpoken(script, said) })} /> <DictateButton label="Dictate script" onText={(said) => set({ finalScript: appendSpoken(script, said) })} />
{notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>} {notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>}
</div> </div>
{heldAction && (
<div role="alert" className="rounded-xl border border-[var(--r-accent)] bg-[var(--r-hl)] p-3 text-sm">
<p className="font-medium">Is this your study, or Claude's?</p>
<p className="mt-0.5 text-xs text-[var(--r-sub)]">These chunks don't have much of your own thinking yet. Write it first, then let Claude polish it.</p>
<ul className="mt-2 space-y-1 text-xs">
{needingWork.map(({ chunk: c, missing }) => (
<li key={c.id} className="flex items-center justify-between gap-3">
<span>{formatChunkReference(project, project.chapters.findIndex((ch) => ch.chunks.some((x) => x.id === c.id)), c, '–')}: {missing.map((m) => `${m.label} ${m.have}/${m.need}`).join(', ')}</span>
<button type="button" onClick={() => goStudy(c.id)} className="text-[var(--r-accent)] hover:underline">Write it →</button>
</li>
))}
</ul>
<div className="mt-2.5 flex gap-2">
<button type="button" onClick={() => goStudy(needingWork[0].chunk.id)} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)]">Go write my notes</button>
<button type="button" onClick={() => { const run = heldAction; setHeldAction(null); run(); }} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs hover:bg-black/5">Send to Claude anyway</button>
<button type="button" onClick={() => setHeldAction(null)} className="text-xs text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</div>
)}
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })} <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." 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)]" /> 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)]" />
+25 -1
View File
@@ -5,6 +5,9 @@ import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx'; import PageShell from '../components/study/PageShell.jsx';
import { READER_TRANSLATIONS } from '../utils/bibleText.js'; import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import DataSection from '../components/settings/DataSection.jsx'; import DataSection from '../components/settings/DataSection.jsx';
import { DashboardSettings, HighlightColours, StudyTemplates, VerseOfDaySettings } from '../components/settings/MoreSettings.jsx';
import { useLocalSetting } from '../hooks/useLocalSetting.js';
import { useIqAvailable } from '../hooks/useIq.js';
import PasskeysSection from '../components/settings/PasskeysSection.jsx'; import PasskeysSection from '../components/settings/PasskeysSection.jsx';
import DevicesSection from '../components/settings/DevicesSection.jsx'; import DevicesSection from '../components/settings/DevicesSection.jsx';
import { COMMENTARIES } from '../hooks/useVerseStudy.js'; import { COMMENTARIES } from '../hooks/useVerseStudy.js';
@@ -61,11 +64,14 @@ export default function SettingsPage() {
readerWide, setReaderWide, readerWide, setReaderWide,
readerCommentary, setReaderCommentary, readerCommentary, setReaderCommentary,
audioNarrator, setAudioNarrator, audioNarrator, setAudioNarrator,
readerFont, setReaderFont, readerSpacing, setReaderSpacing, readerVerseNumbers, setReaderVerseNumbers,
settingsFocus, clearSettingsFocus, settingsFocus, clearSettingsFocus,
} = useApp(); } = useApp();
// "Back up my data" from the ⌘K palette lands on that section // "Back up my data" from the ⌘K palette lands on that section
const narrators = useLazyNarrators(audioNarrator); const narrators = useLazyNarrators(audioNarrator);
const iqOn = useIqAvailable();
const [iqMode, setIqMode] = useLocalSetting('iq-commentary-mode', '0');
const [tab, setTab] = useState(settingsFocus === 'backup' ? 'data' : 'account'); const [tab, setTab] = useState(settingsFocus === 'backup' ? 'data' : 'account');
useEffect(() => { useEffect(() => {
if (!settingsFocus) return; if (!settingsFocus) return;
@@ -235,7 +241,7 @@ export default function SettingsPage() {
}; };
return ( return (
<PageShell title="Account Settings" onBack={goHome} maxWidth="max-w-2xl"> <PageShell title="Account Settings" active="settings" onBack={goHome} maxWidth="max-w-2xl">
<main className="mx-auto max-w-2xl space-y-5 px-4 py-6"> <main className="mx-auto max-w-2xl space-y-5 px-4 py-6">
<div role="tablist" aria-label="Settings sections" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"> <div role="tablist" aria-label="Settings sections" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
{SETTINGS_TABS.map(([id, label]) => ( {SETTINGS_TABS.map(([id, label]) => (
@@ -284,9 +290,23 @@ export default function SettingsPage() {
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)} {COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select> </select>
</PrefRow> </PrefRow>
{iqOn && (
<PrefRow label="Commentary sources" hint="Classic is the short list. More sources adds IQ Bible’s many commentaries.">
<Segmented label="Commentary sources" options={[['0', 'Classic'], ['1', 'More sources']]} value={iqMode} onChange={setIqMode} />
</PrefRow>
)}
<PrefRow label="Text size" hint="Reader text."> <PrefRow label="Text size" hint="Reader text.">
<Segmented label="Text size" options={FONT_SIZES.map(([l, n]) => [n, l])} value={readerFontSize} onChange={setReaderFontSize} /> <Segmented label="Text size" options={FONT_SIZES.map(([l, n]) => [n, l])} value={readerFontSize} onChange={setReaderFontSize} />
</PrefRow> </PrefRow>
<PrefRow label="Typeface">
<Segmented label="Typeface" options={[['serif', 'Serif'], ['sans', 'Sans'], ['easy', 'Easy to read']]} value={readerFont} onChange={setReaderFont} />
</PrefRow>
<PrefRow label="Line spacing">
<Segmented label="Line spacing" options={[['snug', 'Snug'], ['normal', 'Normal'], ['airy', 'Airy']]} value={readerSpacing} onChange={setReaderSpacing} />
</PrefRow>
<PrefRow label="Verse numbers">
<Segmented label="Verse numbers" options={[[true, 'Show'], [false, 'Hide']]} value={readerVerseNumbers} onChange={setReaderVerseNumbers} />
</PrefRow>
<PrefRow label="Reading width" hint="One column, or two on a wide screen."> <PrefRow label="Reading width" hint="One column, or two on a wide screen.">
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={readerWide} onChange={setReaderWide} /> <Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={readerWide} onChange={setReaderWide} />
</PrefRow> </PrefRow>
@@ -389,6 +409,10 @@ export default function SettingsPage() {
<DataSection /> <DataSection />
)} )}
{tab === 'prefs' && <HighlightColours />}
{tab === 'prefs' && <DashboardSettings />}
{tab === 'prefs' && <VerseOfDaySettings />}
{tab === 'prefs' && <StudyTemplates />}
{tab === 'prefs' && ( {tab === 'prefs' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4"> <section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div> <div>
+25
View File
@@ -83,6 +83,31 @@ describe('Authentication', () => {
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument(); expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
}); });
test('every reading and display preference can be set from Settings', async () => {
stubApi({ session: user });
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
await u.click(await screen.findByRole('tab', { name: 'Preferences' }));
for (const name of ['Text size', 'Typeface', 'Line spacing', 'Verse numbers', 'Reading width', 'Verse layout', 'Theme', 'Home page']) {
expect(screen.getByRole('group', { name })).toBeInTheDocument();
}
await u.click(within(screen.getByRole('group', { name: 'Typeface' })).getByRole('button', { name: 'Sans' }));
expect(localStorage.getItem('reader-font')).toBe('sans');
await u.click(within(screen.getByRole('group', { name: 'Verse numbers' })).getByRole('button', { name: 'Hide' }));
expect(localStorage.getItem('reader-verse-numbers')).toBe('off');
// highlight colour names
await u.type(screen.getByLabelText('Name for yellow highlights'), 'Promise');
expect(JSON.parse(localStorage.getItem('reader-color-labels'))['#fef08a']).toBe('Promise');
// the My Studies page
await u.click(within(screen.getByRole('group', { name: 'Show studies as' })).getByRole('button', { name: 'List' }));
expect(localStorage.getItem('homeView')).toBe('list');
await u.selectOptions(screen.getByLabelText('Sort studies by'), 'title');
expect(localStorage.getItem('homeSort')).toBe('title');
expect(screen.getByRole('heading', { name: 'Study templates' })).toBeInTheDocument();
});
test('Settings is split into tabs, one group at a time', async () => { test('Settings is split into tabs, one group at a time', async () => {
stubApi({ session: user }); stubApi({ session: user });
const u = userEvent.setup(); const u = userEvent.setup();
+41
View File
@@ -0,0 +1,41 @@
// Used for the home card's verse of the day when IQ Bible can't be reached: one verse a day, the same for
// everyone, stepping through this list by the day of the year. [book USFM, chapter, verse].
export const FALLBACK_VERSES = [
['GEN', 1, 1], ['GEN', 1, 27], ['GEN', 12, 2], ['GEN', 28, 15], ['GEN', 50, 20],
['EXO', 14, 14], ['EXO', 15, 2], ['EXO', 33, 14], ['NUM', 6, 24], ['DEU', 6, 5],
['DEU', 31, 6], ['JOS', 1, 9], ['1SA', 16, 7], ['2CH', 7, 14], ['NEH', 8, 10],
['JOB', 19, 25], ['PSA', 1, 2], ['PSA', 16, 11], ['PSA', 19, 14], ['PSA', 23, 1],
['PSA', 23, 4], ['PSA', 27, 1], ['PSA', 27, 14], ['PSA', 28, 7], ['PSA', 30, 5],
['PSA', 34, 8], ['PSA', 34, 18], ['PSA', 37, 4], ['PSA', 46, 1], ['PSA', 46, 10],
['PSA', 51, 10], ['PSA', 55, 22], ['PSA', 62, 1], ['PSA', 91, 1], ['PSA', 100, 4],
['PSA', 103, 2], ['PSA', 118, 24], ['PSA', 119, 11], ['PSA', 119, 105], ['PSA', 121, 1],
['PSA', 121, 2], ['PSA', 139, 14], ['PSA', 145, 18], ['PRO', 3, 5], ['PRO', 3, 6],
['PRO', 4, 23], ['PRO', 16, 3], ['PRO', 18, 10], ['PRO', 22, 6], ['ECC', 3, 1],
['ISA', 26, 3], ['ISA', 40, 8], ['ISA', 40, 31], ['ISA', 41, 10], ['ISA', 43, 2],
['ISA', 53, 5], ['ISA', 55, 8], ['JER', 17, 7], ['JER', 29, 11], ['JER', 33, 3],
['LAM', 3, 22], ['EZK', 36, 26], ['DAN', 3, 17], ['MIC', 6, 8], ['HAB', 3, 17],
['ZEP', 3, 17], ['MAL', 3, 6], ['MAT', 5, 3], ['MAT', 5, 14], ['MAT', 5, 16],
['MAT', 6, 33], ['MAT', 7, 7], ['MAT', 11, 28], ['MAT', 19, 26], ['MAT', 22, 37],
['MAT', 28, 19], ['MAT', 28, 20], ['MRK', 10, 45], ['MRK', 12, 30], ['LUK', 1, 37],
['LUK', 6, 31], ['LUK', 11, 9], ['LUK', 19, 10], ['JHN', 1, 1], ['JHN', 1, 14],
['JHN', 3, 16], ['JHN', 6, 35], ['JHN', 8, 12], ['JHN', 8, 32], ['JHN', 10, 10],
['JHN', 11, 25], ['JHN', 13, 34], ['JHN', 14, 6], ['JHN', 14, 27], ['JHN', 15, 5],
['JHN', 16, 33], ['ACT', 1, 8], ['ACT', 4, 12], ['ROM', 1, 16], ['ROM', 3, 23],
['ROM', 5, 8], ['ROM', 6, 23], ['ROM', 8, 1], ['ROM', 8, 28], ['ROM', 8, 31],
['ROM', 8, 38], ['ROM', 10, 9], ['ROM', 12, 2], ['ROM', 15, 13], ['1CO', 10, 13],
['1CO', 13, 4], ['1CO', 13, 13], ['1CO', 15, 58], ['2CO', 5, 17], ['2CO', 12, 9],
['GAL', 2, 20], ['GAL', 5, 22], ['GAL', 6, 9], ['EPH', 2, 8], ['EPH', 2, 10],
['EPH', 3, 20], ['EPH', 4, 32], ['EPH', 6, 10], ['PHP', 1, 6], ['PHP', 4, 4],
['PHP', 4, 6], ['PHP', 4, 7], ['PHP', 4, 13], ['COL', 3, 2], ['COL', 3, 23],
['1TH', 5, 16], ['2TI', 1, 7], ['2TI', 3, 16], ['TIT', 3, 5], ['HEB', 4, 12],
['HEB', 4, 16], ['HEB', 11, 1], ['HEB', 11, 6], ['HEB', 12, 1], ['HEB', 13, 8],
['JAS', 1, 5], ['JAS', 1, 22], ['JAS', 4, 8], ['1PE', 5, 7], ['2PE', 3, 9],
['1JN', 1, 9], ['1JN', 4, 8], ['1JN', 4, 19], ['REV', 3, 20], ['REV', 21, 4],
];
// The verse for a calendar day (UTC, like IQ Bible's own pick, so everyone sees the same one).
export function fallbackVerseFor(date = new Date()) {
const day = Math.floor((Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) - Date.UTC(date.getUTCFullYear(), 0, 0)) / 86400000);
const [book_usfm, chapter_number, verse_number] = FALLBACK_VERSES[day % FALLBACK_VERSES.length];
return { book_usfm, chapter_number, verse_number };
}
+3
View File
@@ -29,6 +29,9 @@ export function mergeChunk(mine, theirs) {
const out = { ...older, ...newer }; const out = { ...older, ...newer };
TEXT_FIELDS.forEach((f) => { out[f] = mergeText(newer[f], older[f]); }); TEXT_FIELDS.forEach((f) => { out[f] = mergeText(newer[f], older[f]); });
LIST_FIELDS.forEach((f) => { out[f] = union(newer[f], older[f]); }); LIST_FIELDS.forEach((f) => { out[f] = union(newer[f], older[f]); });
// Pastes follow their text: keep the union per field so merged notes still show what was pasted.
out.aiPastes = Object.fromEntries(['observation', 'interpretation', 'application'].map((f) => [f, union(newer.aiPastes?.[f], older.aiPastes?.[f])]));
out.recalls = union(newer.recalls, older.recalls);
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined; out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
return { chunk: out, changed: !same(out, mine) }; return { chunk: out, changed: !same(out, mine) };
} }
+49
View File
@@ -0,0 +1,49 @@
// "Is this study my own work?" — how much of each chunk's Observe / Interpret /
// Apply notes has been written by hand (long pastes don't count as your own). Used to hold back the "→ Claude" prompts
// until the thinking has been done by you, not handed to AI.
export const OWN_WORK_MINIMUMS = { observation: 20, interpretation: 20, application: 10 };
const LABELS = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply' };
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
// A paste this long into a note box is remembered as "not typed by me" (chunk.aiPastes[field] = [text, …]).
export const PASTE_MIN_WORDS = 15;
export const NOTE_FIELDS = Object.keys(OWN_WORK_MINIMUMS);
// -> the chunk's new aiPastes after pasting `pasted` into `field`, or null when the paste is too short to count.
export function withPaste(chunk, field, pasted) {
const text = (pasted ?? '').trim();
if (words(text) < PASTE_MIN_WORDS) return null;
const all = chunk?.aiPastes ?? {};
return { ...all, [field]: [...(all[field] ?? []), text] };
}
// Words in `field` that came from a long paste still present in the note (delete or rewrite it and it stops counting).
export function pastedWords(chunk, field) {
const body = chunk?.[field] ?? '';
return (chunk?.aiPastes?.[field] ?? []).filter((t) => body.includes(t)).reduce((n, t) => n + words(t), 0);
}
export const ownWords = (chunk, field) => Math.max(0, words(chunk?.[field]) - pastedWords(chunk, field));
// Share of the three notes' words that are typed or dictated rather than pasted, 0–100 (null when nothing is written).
export function ownShare(chunk) {
const total = NOTE_FIELDS.reduce((n, f) => n + words(chunk?.[f]), 0);
if (!total) return null;
return Math.round((NOTE_FIELDS.reduce((n, f) => n + ownWords(chunk, f), 0) / total) * 100);
}
// "That's mine": stop counting the pastes in one field.
export const clearPastes = (chunk, field) => ({ ...(chunk?.aiPastes ?? {}), [field]: [] });
// The note fields of one chunk that are still under their minimum word count.
export function thinFields(chunk) {
return Object.entries(OWN_WORK_MINIMUMS)
.filter(([field, min]) => ownWords(chunk, field) < min)
.map(([field]) => ({ field, label: LABELS[field], have: ownWords(chunk, field), need: OWN_WORK_MINIMUMS[field] }));
}
// Chunks that still need your own notes, with what's missing in each.
export function chunksNeedingWork(chunks) {
return (chunks ?? []).map((chunk) => ({ chunk, missing: thinFields(chunk) })).filter((entry) => entry.missing.length > 0);
}
+58
View File
@@ -0,0 +1,58 @@
import { test, expect } from 'vitest';
import { thinFields, chunksNeedingWork } from './ownWork.js';
const text = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
test('a field counts as done once it reaches its minimum word count', () => {
const chunk = { observation: text(20), interpretation: text(19), application: text(10) };
expect(thinFields(chunk).map((f) => f.field)).toEqual(['interpretation']);
});
test('empty or missing notes are all flagged', () => {
expect(thinFields({}).map((f) => f.label)).toEqual(['Observe', 'Interpret', 'Apply']);
expect(thinFields(null)).toHaveLength(3);
});
test('only chunks with something missing are returned', () => {
const done = { id: 'a', observation: text(25), interpretation: text(25), application: text(12) };
const thin = { id: 'b', observation: text(25), interpretation: '', application: text(12) };
const result = chunksNeedingWork([done, thin]);
expect(result.map((r) => r.chunk.id)).toEqual(['b']);
expect(result[0].missing[0]).toMatchObject({ label: 'Interpret', have: 0, need: 20 });
});
import { withPaste, pastedWords, ownWords, ownShare, clearPastes } from './ownWork.js';
import { mergeChunk } from './mergeStudies.js';
test('only long pastes are remembered', () => {
expect(withPaste({}, 'observation', text(5))).toBeNull();
expect(withPaste({}, 'observation', text(15))).toEqual({ observation: [text(15)] });
});
test('pasted text stops counting once it is removed from the note', () => {
const pasted = text(30);
const chunk = { observation: `my own thought ${pasted}`, aiPastes: { observation: [pasted] } };
expect(pastedWords(chunk, 'observation')).toBe(30);
expect(ownWords(chunk, 'observation')).toBe(3);
expect(ownWords({ ...chunk, observation: 'now I wrote this myself' }, 'observation')).toBe(5);
});
test('pasted words do not satisfy the own-work minimum', () => {
const pasted = text(40);
const chunk = { observation: pasted, interpretation: text(25), application: text(12), aiPastes: { observation: [pasted] } };
expect(thinFields(chunk).map((f) => f.field)).toEqual(['observation']);
});
test('the own-words share and "That\'s mine"', () => {
const pasted = text(30);
const chunk = { observation: `${text(10)} ${pasted}`, aiPastes: { observation: [pasted] } };
expect(ownShare(chunk)).toBe(25);
expect(ownShare({})).toBeNull();
expect(ownShare({ ...chunk, aiPastes: clearPastes(chunk, 'observation') })).toBe(100);
});
test('merging two copies keeps the pastes from both', () => {
const a = { id: 'x', observation: 'a', editedAt: 2, aiPastes: { observation: ['p1'] } };
const b = { id: 'x', observation: 'b', editedAt: 1, aiPastes: { observation: ['p2'] } };
expect(mergeChunk(a, b).chunk.aiPastes.observation).toEqual(['p1', 'p2']);
});
+51
View File
@@ -0,0 +1,51 @@
// Weekly recall check: once a week, pick a passage you studied a while ago and ask you to write what you
// remember of it before seeing your notes. Recalls are kept on the chunk (chunk.recalls = [{ at, text, rating }]).
import { formatChunkReference } from './bibleUtils.jsx';
import { NOTE_FIELDS, ownWords } from './ownWork.js';
export const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
export const MIN_OWN_WORDS = 30; // a chunk needs this much of your own writing to be worth testing
export const RATINGS = ['solid', 'partly', 'forgot'];
const lastRecallAt = (chunk) => Math.max(0, ...(chunk.recalls ?? []).map((r) => r.at ?? 0));
const ownTotal = (chunk) => NOTE_FIELDS.reduce((n, f) => n + ownWords(chunk, f), 0);
// -> the passage to recall this week, or null when you already did one in the last 7 days or nothing qualifies.
// `projects` are full study projects. Studied at least a week ago, never-recalled first, then the longest ago.
export function pickRecall(projects, now = Date.now()) {
const all = (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).map((chunk) => ({ project, chapterIndex, chunk }))));
if (all.some(({ chunk }) => lastRecallAt(chunk) > 0 && now - lastRecallAt(chunk) < WEEK_MS)) return null;
const [best] = all
.filter(({ chunk }) => ownTotal(chunk) >= MIN_OWN_WORDS && now - (chunk.editedAt ?? 0) >= WEEK_MS)
.sort((a, b) => lastRecallAt(a.chunk) - lastRecallAt(b.chunk) || (a.chunk.editedAt ?? 0) - (b.chunk.editedAt ?? 0));
if (!best) return null;
return {
projectId: best.project.id, projectTitle: best.project.title, chunk: best.chunk,
reference: formatChunkReference(best.project, best.chapterIndex, best.chunk, '–'),
};
}
// Every recall ever saved, newest first: [{ at, rating, text, reference, projectId, projectTitle, chunkId }].
export function recallHistory(projects) {
return (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).flatMap((chunk) => (
(chunk.recalls ?? []).map((r) => ({
at: r.at ?? 0, rating: r.rating, text: r.text ?? '', chunkId: chunk.id,
projectId: project.id, projectTitle: project.title, reference: formatChunkReference(project, chapterIndex, chunk, '–'),
}))
)))).sort((a, b) => b.at - a.at);
}
// How the recalls went: counts per rating, plus the last `weeks` weeks (oldest first) as { start, rating|null }
// where rating is the weakest one that week (so a week isn't flattered by one good recall).
export function recallSummary(history, now = Date.now(), weeks = 8) {
const counts = { solid: 0, partly: 0, forgot: 0 };
history.forEach((h) => { if (h.rating in counts) counts[h.rating] += 1; });
const rank = { forgot: 0, partly: 1, solid: 2 };
const recent = Array.from({ length: weeks }, (_, i) => {
const start = now - (weeks - i) * WEEK_MS;
const inWeek = history.filter((h) => h.at > start && h.at <= start + WEEK_MS && h.rating in rank);
const weakest = inWeek.sort((a, b) => rank[a.rating] - rank[b.rating])[0];
return { start, rating: weakest?.rating ?? null };
});
return { counts, total: history.length, recent };
}
+58
View File
@@ -0,0 +1,58 @@
import { test, expect } from 'vitest';
import { pickRecall, WEEK_MS } from './recall.js';
const NOW = 100 * WEEK_MS;
const text = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
const chunk = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 5, observation: text(40), editedAt: NOW - 2 * WEEK_MS, ...extra });
const study = (chunks, id = 'p1') => ({ id, title: 'Romans', translation: 'BSB', chapters: [{ book: 'Romans', bookAbbrev: 'Rom', chapter: 8, chunks }] });
test('picks a studied chunk that has enough of your own writing', () => {
const pick = pickRecall([study([chunk('a')])], NOW);
expect(pick).toMatchObject({ projectId: 'p1', projectTitle: 'Romans' });
expect(pick.chunk.id).toBe('a');
});
test('skips thin notes, pasted notes and chunks edited this week', () => {
const pasted = text(40);
const projects = [study([
chunk('thin', { observation: text(10) }),
chunk('pasted', { observation: pasted, aiPastes: { observation: [pasted] } }),
chunk('fresh', { editedAt: NOW - 1000 }),
])];
expect(pickRecall(projects, NOW)).toBeNull();
});
test('never-recalled chunks come before ones recalled long ago', () => {
const old = chunk('old', { recalls: [{ at: NOW - 3 * WEEK_MS, text: 'x', rating: 'solid' }] });
expect(pickRecall([study([old, chunk('new')])], NOW).chunk.id).toBe('new');
});
test('one recall a week: nothing is offered after you have done one', () => {
const done = chunk('done', { recalls: [{ at: NOW - 1000, text: 'x', rating: 'partly' }] });
expect(pickRecall([study([done, chunk('other')])], NOW)).toBeNull();
expect(pickRecall([study([done, chunk('other')])], NOW + WEEK_MS)).not.toBeNull();
});
import { recallHistory, recallSummary } from './recall.js';
test('history lists every recall newest first, with where it came from', () => {
const a = chunk('a', { recalls: [{ at: NOW - 3 * WEEK_MS, text: 'old', rating: 'forgot' }, { at: NOW - 1000, text: 'new', rating: 'solid' }] });
const h = recallHistory([study([a])]);
expect(h.map((x) => x.text)).toEqual(['new', 'old']);
expect(h[0]).toMatchObject({ projectTitle: 'Romans', chunkId: 'a' });
expect(h[0].reference).toContain('Romans');
});
test('the summary counts ratings and marks each of the last eight weeks by its weakest recall', () => {
const history = [
{ at: NOW - 1000, rating: 'solid' }, { at: NOW - 2000, rating: 'partly' },
{ at: NOW - 2 * WEEK_MS + 1000, rating: 'forgot' },
];
const { counts, total, recent } = recallSummary(history, NOW);
expect(counts).toEqual({ solid: 1, partly: 1, forgot: 1 });
expect(total).toBe(3);
expect(recent).toHaveLength(8);
expect(recent[7].rating).toBe('partly'); // this week: the weaker of solid and partly
expect(recent[6].rating).toBe('forgot'); // last week
expect(recent[0].rating).toBeNull();
});