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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-13 10:30:04 -04:00
1 parent 512c6b6889
commit edc5a65d2b
4 files changed
+78 -13

No files matched your search

+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' };
+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); });
+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 };
}