Invite-only sign-up, present mode, series, handouts, templates, dictation, progress, sharing
Build and Push Docker Image / build-and-push (push) Successful in 6m47s

- Admin: sign-up Open / Invite only / Closed with single-use invite links
- Present mode for scripts (auto-scroll) and outlines (step through, pace vs planned minutes)
- Series: group studies on the dashboard, filter by series, calendar file for a study's sessions
- Lesson handout: printable page per session (passage, outline, questions, writing lines)
- Save a chunk's notes as your own study template (synced)
- Voice dictation for notes and scripts (browser speech recognition)
- Reading progress: map of the 66 books and a calendar of days read, synced and merged across devices
- Export highlights and bookmarks as Markdown or Word, grouped by named colour
- Verse of the day push to the ntfy topic (needs IQBIBLE_API_KEY)
- Sharing: co-teacher editing via single-use invite links; read-only links can expire and take a passcode

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:26:43 -04:00
1 parent 88bc59bac0
commit 54cb1c217d
73 files changed
+2739 -134

No files matched your search

+26 -2
View File
@@ -5,6 +5,7 @@ import { AppContext, useApp } from './context/AppContext.js';
import { ToastProvider, useToast } from './components/Toasts.jsx';
import CommandPalette from './components/CommandPalette.jsx';
import ShortcutsHelp from './components/ShortcutsHelp.jsx';
import SharedPasscodeForm from './components/SharedPasscodeForm.jsx';
import SharedOutlineView from './components/SharedOutlineView.jsx';
import Tooltips from './components/Tooltips.jsx';
import { useAudio } from './hooks/useAudio.js';
@@ -12,11 +13,12 @@ import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useHistoryNav } from './hooks/useHistoryNav.js';
import { useReadingHistory } from './hooks/useReadingHistory.js';
import { useReadingPlans } from './hooks/useReadingPlans.js';
import { useSharedView } from './hooks/useSharedView.js';
import HomePage from './pages/HomePage.jsx';
import { themeVars } from './components/reader/readerThemes.js';
import { loadRemoteProject, saveRemoteProject } from './syncService.js';
import { joinStudy, loadRemoteProject, saveRemoteProject } from './syncService.js';
import { getSyncBase, setSyncBase } from './utils/syncBase.js';
import {
bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser,
@@ -73,6 +75,7 @@ const AppInner = () => {
const [readerPlan, setReaderPlan] = useState(null);
const readerPlanProgress = readerPlan ? readingPlans.planProgress.find((p) => p.id === readerPlan.progressId) ?? null : null;
const reader = useBibleReader(currentPage, readerPlanProgress?.translation ?? null);
const readingHistory = useReadingHistory({ active: currentPage === 'reader', book: reader.readerBookAbbrev, chapter: reader.readerChapter });
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation);
useEffect(() => { if (currentPage !== 'reader') setReaderPlan(null); }, [currentPage]);
// A new page starts at the top. Without this the scroll position of the previous page carries over, and on
@@ -99,11 +102,14 @@ const AppInner = () => {
reader.pullServerMarks();
// Theme, default translation, layout, commentary and reading position.
reader.pullServerPrefs();
// What you've read, merged with this device's.
readingHistory.pullReadingHistory();
},
onSignOut: () => {
reader.stopMarksSync();
reader.stopPrefsSync();
reader.stopInkSync();
readingHistory.stopReadingHistory();
readingPlans.stopReadingPlansSync();
setProjectIndex(switchStorageUser(null));
goStudies();
@@ -117,6 +123,22 @@ const AppInner = () => {
setCurrentPage,
});
// Opening an invite link (?join=TOKEN) while signed in adds that study to your list, ready to edit.
useEffect(() => {
if (!authUser) return;
const token = new URLSearchParams(window.location.search).get('join');
if (!token) return;
window.history.replaceState({}, '', window.location.pathname);
joinStudy(token).then(async (joined) => {
if (!joined.ok) { toast(joined.error ?? 'Could not use that invite link.', { tone: 'error' }); return; }
const full = await loadRemoteProject(joined.data.projectId);
if (full.ok) { saveProjectToStorage(full.data); setSyncBase(full.data.id, full.data.lastEdited); setProjectIndex(loadProjectIndex()); }
library.refreshLibrary();
toast(`You can now edit “${joined.data.title}”. It's in My Studies.`);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authUser]);
// ⌘K search/go-to, the "?" shortcut list, and requests other pages make of the Reader.
const [paletteOpen, setPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
@@ -479,6 +501,7 @@ const AppInner = () => {
...audio,
// Reader
...reader,
readingHistory: readingHistory.readingHistory,
readerPlan, setReaderPlan,
readerPlanActive: !!readerPlanProgress,
openPlanReading,
@@ -523,13 +546,14 @@ function PageLoading() {
function AppRouter() {
const { currentPage, authUser, authServerDown } = useApp();
const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView();
const { token: sharedViewToken, project: sharedProject, error: sharedError, needsPasscode, passcodeError, checking, submitPasscode } = useSharedView();
const sharedOutlineToken = new URLSearchParams(window.location.search).get('outline');
// A read-only outline link: no sign-in, nothing editable
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
if (sharedViewToken) {
if (needsPasscode) return <SharedPasscodeForm dark error={passcodeError} busy={checking} onSubmit={submitPasscode} />;
if (sharedError) {
return (
<div className="flex min-h-dvh items-center justify-center bg-slate-900 px-4">
+87
View File
@@ -0,0 +1,87 @@
// Admin → Sign-ups: who may create an account, and invite codes for when it is invite-only.
import { useEffect, useState } from 'react';
import { adminCreateInvite, adminDeleteInvite, adminGetSignup, adminSetSignupMode } from '../syncService.js';
const MODES = [
['open', 'Open', 'Anyone who can reach this site can create an account.'],
['invite', 'Invite only', 'A new account needs an invite code from you.'],
['closed', 'Closed', 'Nobody new can sign up.'],
];
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
export default function AdminSignup() {
const [state, setState] = useState({ mode: 'open', invites: [], loading: true, error: '' });
const [note, setNote] = useState('');
const [days, setDays] = useState(7);
const [copied, setCopied] = useState('');
const load = async () => {
const r = await adminGetSignup();
setState(r.ok ? { ...r.data, loading: false, error: '' } : { mode: 'open', invites: [], loading: false, error: r.error ?? 'Could not load sign-up settings.' });
};
useEffect(() => { load(); }, []);
const setMode = async (mode) => {
const r = await adminSetSignupMode(mode);
if (r.ok) setState((s) => ({ ...s, mode, error: '' })); else setState((s) => ({ ...s, error: r.error ?? 'Could not change that.' }));
};
const create = async () => {
const r = await adminCreateInvite({ note, days });
if (!r.ok) { setState((s) => ({ ...s, error: r.error ?? 'Could not make an invite.' })); return; }
setNote('');
load();
};
const link = (code) => `${window.location.origin}/?invite=${code}`;
const copy = async (code) => {
try { await navigator.clipboard.writeText(link(code)); setCopied(code); window.setTimeout(() => setCopied(''), 1800); } catch { /* clipboard unavailable */ }
};
const status = (inv) => (inv.usedBy ? 'Used' : inv.expiresAt < Date.now() ? 'Expired' : `Until ${new Date(inv.expiresAt).toLocaleDateString()}`);
if (state.loading) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
return (
<div className="space-y-5">
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Who can sign up">
<h2 className="text-base font-medium">Who can sign up</h2>
<div role="radiogroup" aria-label="Sign-up setting" className="mt-3 space-y-2">
{MODES.map(([id, label, hint]) => (
<label key={id} className="flex cursor-pointer items-start gap-3 text-sm">
<input type="radio" name="signup-mode" checked={state.mode === id} onChange={() => setMode(id)} className="mt-1 accent-[var(--r-accent)]" />
<span><span className="font-medium">{label}</span><span className="block text-xs text-[var(--r-sub)]">{hint}</span></span>
</label>
))}
</div>
{state.error && <p role="alert" className="mt-3 text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>}
</section>
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Invite codes">
<h2 className="text-base font-medium">Invite codes</h2>
<p className="mt-1 text-xs text-[var(--r-sub)]">Each code works once. Send the link, and it opens the sign-up page with the code filled in.{state.mode !== 'invite' ? ' (Codes only matter while sign-up is “Invite only”.)' : ''}</p>
<div className="mt-3 flex flex-wrap items-end gap-2">
<label className="text-xs text-[var(--r-sub)]">Who is it for (optional)
<input type="text" value={note} onChange={(e) => setNote(e.target.value)} maxLength={80} className="mt-1 block w-52 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]" />
</label>
<label className="text-xs text-[var(--r-sub)]">Good for
<select value={days} onChange={(e) => setDays(Number(e.target.value))} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
{[1, 7, 30].map((d) => <option key={d} value={d}>{d} day{d === 1 ? '' : 's'}</option>)}
</select>
</label>
<button type="button" onClick={create} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90">Make an invite</button>
</div>
{state.invites.length > 0 && (
<ul className="mt-4 divide-y divide-[var(--r-line)] text-sm">
{state.invites.map((inv) => (
<li key={inv.code} className="flex flex-wrap items-center justify-between gap-2 py-2">
<span><span className="font-mono tracking-wider">{inv.code}</span>{inv.note ? <span className="ml-2 text-[var(--r-sub)]">{inv.note}</span> : null}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-[var(--r-sub)]">{status(inv)}</span>
{!inv.usedBy && inv.expiresAt >= Date.now() && <button type="button" onClick={() => copy(inv.code)} className={btn}>{copied === inv.code ? 'Copied' : 'Copy link'}</button>}
<button type="button" onClick={async () => { await adminDeleteInvite(inv.code); load(); }} className={`${btn} text-[var(--r-danger,#b42318)]`}>Delete</button>
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
// Export → Co-teachers: let other accounts edit this study with you. You make a single-use invite link;
// whoever opens it (signed in) can edit the study, and you can remove them any time. Edits from both of you
// sync through the account; if you both change the same study, "Combine both" keeps everything.
import { useCallback, useEffect, useState } from 'react';
import { createEditInvite, getCollaborators, removeCollaborator } from '../syncService.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
export default function CoTeachers({ projectId, sharedBy, onLeave }) {
const [state, setState] = useState({ status: 'loading', collaborators: [], pendingInvites: 0 });
const [invite, setInvite] = useState(null); // { url, expiresAt }
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
if (!projectId || sharedBy !== undefined) return;
const r = await getCollaborators(projectId);
setState(r.ok ? { status: 'ready', ...r.data } : { status: 'unavailable', collaborators: [], pendingInvites: 0 });
}, [projectId, sharedBy]);
useEffect(() => { setInvite(null); load(); }, [load]);
// Someone else's study that you were invited to edit
if (sharedBy !== undefined) {
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Co-teachers">
<h2 className="text-sm font-medium">Shared with you</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">{sharedBy || 'Its owner'} invited you to edit this study. Your changes sync to them, and theirs to you.</p>
<button type="button" onClick={onLeave} className={btn}>Leave this study</button>
</section>
);
}
const makeInvite = async () => {
setBusy(true); setError('');
const r = await createEditInvite(projectId);
setBusy(false);
if (!r.ok) { setError(r.error ?? 'Could not make the link.'); return; }
setInvite({ url: `${window.location.origin}${window.location.pathname}?join=${r.data.token}`, expiresAt: r.data.expiresAt });
load();
};
const copy = async () => {
try { await navigator.clipboard.writeText(invite.url); setCopied(true); window.setTimeout(() => setCopied(false), 1800); } catch { setError('Couldn’t copy. Select the link and copy it.'); }
};
const remove = async (userId, email) => {
if (!window.confirm(`Remove ${email}? They will no longer be able to edit this study.`)) return;
const r = await removeCollaborator(projectId, userId);
if (r.ok) load(); else setError(r.error ?? 'Could not remove them.');
};
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Co-teachers">
<h2 className="text-sm font-medium">Co-teachers</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Let someone else edit this study with you. They need an account here. Each invite link works once and lasts 7 days.</p>
{state.status === 'unavailable' && <p className="text-xs text-[var(--r-sub)]">Invites need your account and a connection, and the study has to have synced once. Try again in a moment.</p>}
{state.status === 'ready' && (
<>
{state.collaborators.length > 0 && (
<ul className="mb-3 divide-y divide-[var(--r-line)] text-sm">
{state.collaborators.map((c) => (
<li key={c.userId} className="flex items-center justify-between gap-3 py-2">
<span>{c.email}</span>
<button type="button" onClick={() => remove(c.userId, c.email)} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</li>
))}
</ul>
)}
{invite ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input readOnly aria-label="Invite link" value={invite.url} onFocus={(e) => e.target.select()} className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
<button type="button" onClick={copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{copied ? 'Copied!' : 'Copy'}</button>
</div>
<p className="text-[11px] text-[var(--r-sub)]">Send it to one person. It stops working once they use it, or on {new Date(invite.expiresAt).toLocaleDateString()}.</p>
</div>
) : (
<button type="button" onClick={makeInvite} disabled={busy} className={btn}>{busy ? 'Making a link…' : 'Invite a co-teacher'}</button>
)}
{state.pendingInvites > 0 && !invite && <p className="mt-2 text-[11px] text-[var(--r-sub)]">{state.pendingInvites} invite{state.pendingInvites === 1 ? '' : 's'} waiting to be used.</p>}
</>
)}
{error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{error}</p>}
</section>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
vi.mock('../syncService.js', () => ({ getCollaborators: vi.fn(), createEditInvite: vi.fn(), removeCollaborator: vi.fn() }));
import CoTeachers from './CoTeachers.jsx';
import { createEditInvite, getCollaborators, removeCollaborator } from '../syncService.js';
beforeEach(() => {
vi.clearAllMocks();
getCollaborators.mockResolvedValue({ ok: true, data: { collaborators: [{ userId: 'u2', email: 'sam@x.co' }], pendingInvites: 0 } });
createEditInvite.mockResolvedValue({ ok: true, data: { token: 'abc123', expiresAt: Date.now() + 7 * 86_400_000 } });
removeCollaborator.mockResolvedValue({ ok: true });
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
test('the owner sees the co-teachers and can make a single-use invite link', async () => {
const user = userEvent.setup();
render(<CoTeachers projectId="p1" />);
expect(await screen.findByText('sam@x.co')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /invite a co-teacher/i }));
const link = await screen.findByLabelText('Invite link');
expect(link.value).toContain('?join=abc123');
expect(createEditInvite).toHaveBeenCalledWith('p1');
});
test('the owner can remove a co-teacher after confirming', async () => {
const user = userEvent.setup();
render(<CoTeachers projectId="p1" />);
await user.click(await screen.findByRole('button', { name: 'Remove' }));
expect(removeCollaborator).toHaveBeenCalledWith('p1', 'u2');
});
test('someone invited to a study sees who shared it and can leave', async () => {
const onLeave = vi.fn();
const user = userEvent.setup();
render(<CoTeachers projectId="p1" sharedBy="owner@x.co" onLeave={onLeave} />);
expect(screen.getByText(/owner@x.co invited you to edit/)).toBeInTheDocument();
expect(getCollaborators).not.toHaveBeenCalled(); // only owners can list co-teachers
await user.click(screen.getByRole('button', { name: /leave this study/i }));
expect(onLeave).toHaveBeenCalled();
});
test('when the server can\'t be reached, it says so instead of offering a link', async () => {
getCollaborators.mockResolvedValue({ ok: false, status: 404, error: 'Project not found.' });
render(<CoTeachers projectId="p1" />);
await waitFor(() => expect(screen.getByText(/Invites need your account/)).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /invite a co-teacher/i })).toBeNull();
});
+68
View File
@@ -0,0 +1,68 @@
// A microphone button for a text box: tap, speak, and what you say is added to the text. It uses the browser's
// own speech recognition (Safari and Chrome have it; Firefox does not, and then the button isn't shown). The
// browser may send the audio to its maker's speech service to be turned into words, which is why it asks to use
// the microphone, and why this is a button you press rather than something that listens in the background.
import { useEffect, useRef, useState } from 'react';
export const speechRecognitionClass = () => (typeof window === 'undefined' ? null : window.SpeechRecognition ?? window.webkitSpeechRecognition ?? null);
// Adds spoken words to existing text: a space (or nothing after a newline) between, and a first letter capitalised after a full stop.
export function appendSpoken(existing, spoken) {
const said = String(spoken ?? '').trim();
if (!said) return existing ?? '';
const base = existing ?? '';
if (!base.trim()) return `${said.charAt(0).toUpperCase()}${said.slice(1)}`;
const gap = /[\s]$/.test(base) ? '' : ' ';
const afterStop = /[.!?]["')\]]*\s*$/.test(base);
return `${base}${gap}${afterStop ? `${said.charAt(0).toUpperCase()}${said.slice(1)}` : said}`;
}
const MESSAGES = {
'not-allowed': 'The microphone is blocked. Allow it for this site in your browser’s settings.',
'service-not-allowed': 'The microphone is blocked. Allow it for this site in your browser’s settings.',
'audio-capture': 'No microphone was found.',
network: 'Speech recognition needs a connection.',
};
export default function DictateButton({ onText, label = 'Dictate', className = '' }) {
const Recognition = speechRecognitionClass();
const [listening, setListening] = useState(false);
const [error, setError] = useState('');
const recRef = useRef(null);
const onTextRef = useRef(onText);
onTextRef.current = onText;
useEffect(() => () => { try { recRef.current?.abort(); } catch { /* already stopped */ } }, []);
if (!Recognition) return null;
const start = () => {
setError('');
const rec = new Recognition();
rec.lang = navigator.language || 'en-US';
rec.continuous = true;
rec.interimResults = false;
rec.onresult = (event) => {
for (let i = event.resultIndex; i < event.results.length; i += 1) {
if (event.results[i].isFinal) onTextRef.current(event.results[i][0].transcript);
}
};
rec.onerror = (event) => { if (event.error !== 'no-speech' && event.error !== 'aborted') setError(MESSAGES[event.error] ?? 'Dictation stopped. Try again.'); };
rec.onend = () => { setListening(false); recRef.current = null; };
recRef.current = rec;
try { rec.start(); setListening(true); } catch { setListening(false); }
};
const stop = () => { try { recRef.current?.stop(); } catch { /* already stopped */ } };
return (
<span className={`inline-flex items-center gap-2 ${className}`}>
<button type="button" onClick={listening ? stop : start} aria-pressed={listening} aria-label={listening ? 'Stop dictating' : label}
title={listening ? 'Stop dictating' : 'Speak your notes'}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${listening ? 'border-[var(--r-danger,#b42318)] text-[var(--r-danger,#b42318)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
<svg viewBox="0 0 20 20" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true"><rect x="7" y="2.5" width="6" height="10" rx="3" /><path d="M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" strokeLinecap="round" /></svg>
{listening ? 'Listening… tap to stop' : label}
{listening && <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--r-danger,#b42318)] motion-reduce:animate-none" aria-hidden="true" />}
</button>
{error && <span role="alert" className="text-[11px] text-[var(--r-danger,#b42318)]">{error}</span>}
</span>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, afterEach } from 'vitest';
import DictateButton, { appendSpoken } from './DictateButton.jsx';
afterEach(() => { delete window.SpeechRecognition; delete window.webkitSpeechRecognition; });
// A stand-in for the browser's recogniser that we can make "hear" things
function fakeRecognition() {
const instances = [];
class Fake {
constructor() { instances.push(this); this.started = false; }
start() { this.started = true; }
stop() { this.started = false; this.onend?.(); }
abort() { this.started = false; }
}
window.webkitSpeechRecognition = Fake;
return instances;
}
const hear = (rec, text, isFinal = true) => rec.onresult({ resultIndex: 0, results: [Object.assign([{ transcript: text }], { isFinal })] });
test('spoken words are added to what is already written', () => {
expect(appendSpoken('', ' grace is a gift ')).toBe('Grace is a gift');
expect(appendSpoken('Paul writes', 'to Titus')).toBe('Paul writes to Titus');
expect(appendSpoken('He saved us.', 'not by works')).toBe('He saved us. Not by works');
expect(appendSpoken('Line one\n', 'line two')).toBe('Line one\nline two');
expect(appendSpoken('Keep this', ' ')).toBe('Keep this');
});
test('with no speech recognition in the browser, the button is not shown', () => {
const { container } = render(<DictateButton onText={() => {}} />);
expect(container).toBeEmptyDOMElement();
});
test('tap to listen, final phrases are passed on, tap again to stop', async () => {
const instances = fakeRecognition();
const onText = vi.fn();
const user = userEvent.setup();
render(<DictateButton onText={onText} label="Dictate script" />);
await user.click(screen.getByRole('button', { name: 'Dictate script' }));
expect(instances[0].started).toBe(true);
expect(screen.getByRole('button', { name: 'Stop dictating' })).toHaveAttribute('aria-pressed', 'true');
act(() => hear(instances[0], 'the Lord is my shepherd', false)); // still being worked out: ignored
act(() => hear(instances[0], 'the Lord is my shepherd'));
expect(onText).toHaveBeenCalledTimes(1);
expect(onText).toHaveBeenCalledWith('the Lord is my shepherd');
await user.click(screen.getByRole('button', { name: 'Stop dictating' }));
expect(screen.getByRole('button', { name: 'Dictate script' })).toHaveAttribute('aria-pressed', 'false');
});
test('a blocked microphone is explained', async () => {
const instances = fakeRecognition();
const user = userEvent.setup();
render(<DictateButton onText={() => {}} />);
await user.click(screen.getByRole('button', { name: 'Dictate' }));
act(() => instances[0].onerror({ error: 'not-allowed' }));
expect(await screen.findByRole('alert')).toHaveTextContent(/microphone is blocked/i);
act(() => instances[0].onerror({ error: 'no-speech' })); // silence is not an error worth showing
});
+29
View File
@@ -0,0 +1,29 @@
// Export → Lesson handout: a printable page for each session (or one session), for a group to follow along.
import { useState } from 'react';
import { printHandout, sessionsFor } from '../utils/handout.js';
export default function HandoutExport({ project }) {
const sessions = sessionsFor(project);
const [which, setWhich] = useState('all');
const [lines, setLines] = useState(true);
const [blocked, setBlocked] = useState(false);
if (sessions.length === 0) return null;
const go = () => setBlocked(!printHandout(project, which === 'all' ? null : [which], { answerLines: lines }));
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Lesson handout">
<h2 className="text-sm font-medium">Lesson handout</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">A page per session with the passage, the outline, discussion questions and room to write. Use “Save as PDF” in the print dialog. Write the questions in the Study stage under “More”; without them, your Application notes are used.</p>
<div className="flex flex-wrap items-end gap-3">
<label className="text-xs text-[var(--r-sub)]">Sessions
<select value={which} onChange={(e) => setWhich(e.target.value)} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
<option value="all">All {sessions.length} (one page each)</option>
{sessions.map((s) => <option key={s.chunk.id} value={s.chunk.id}>{s.number}. {s.reference}</option>)}
</select>
</label>
<label className="flex items-center gap-2 pb-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={lines} onChange={(e) => setLines(e.target.checked)} className="accent-[var(--r-accent)]" /> Lines to write on</label>
<button type="button" onClick={go} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Print / Save PDF</button>
</div>
{blocked && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Allow pop-ups for this site to print the handout.</p>}
</section>
);
}
+45
View File
@@ -0,0 +1,45 @@
// The rules for a read-only link: when it stops working, and an optional passcode. Shown under the link.
import { useState } from 'react';
const OPTIONS = [['', 'Never'], ['1', 'In 1 day'], ['7', 'In 7 days'], ['30', 'In 30 days']];
const field = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
export const expiryText = (expiresAt, now = Date.now()) => {
if (!expiresAt) return 'Never expires';
if (expiresAt <= now) return 'Expired';
const days = Math.ceil((expiresAt - now) / 86_400_000);
return `Expires ${new Date(expiresAt).toLocaleDateString()} (${days} day${days === 1 ? '' : 's'} left)`;
};
export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) {
const [days, setDays] = useState('keep'); // 'keep' | '' | '1' | '7' | '30'
const [passcode, setPasscode] = useState('');
const dirty = days !== 'keep' || passcode.trim() !== '';
const save = async () => {
const options = {};
if (days !== 'keep') options.expiresInDays = days === '' ? null : Number(days);
if (passcode.trim()) options.passcode = passcode.trim();
if (await onSave(options)) { setDays('keep'); setPasscode(''); }
};
return (
<div className="mt-3 rounded-xl border border-[var(--r-line)] p-3" role="group" aria-label="Link settings">
<p className="text-xs text-[var(--r-sub)]">{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}</p>
<div className="mt-2 flex flex-wrap items-end gap-3">
<label className="text-xs text-[var(--r-sub)]">Stop working
<select value={days} onChange={(e) => setDays(e.target.value)} className={`${field} mt-1 block`}>
<option value="keep">{expiresAt ? 'Keep as is' : 'Never (as is)'}</option>
{OPTIONS.map(([v, label]) => <option key={v || 'never'} value={v}>{label}</option>)}
</select>
</label>
<label className="text-xs text-[var(--r-sub)]">{hasPasscode ? 'New passcode' : 'Passcode (optional)'}
<input type="text" value={passcode} onChange={(e) => setPasscode(e.target.value)} maxLength={100} autoComplete="off" placeholder={hasPasscode ? 'Type to change' : 'None'} className={`${field} mt-1 block w-44`} />
</label>
<button type="button" onClick={save} disabled={!dirty || busy} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-50">Save settings</button>
{hasPasscode && <button type="button" onClick={() => onSave({ passcode: null })} disabled={busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">Remove passcode</button>}
</div>
<p className="mt-2 text-[11px] text-[var(--r-sub)]">Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.</p>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
import LinkRules, { expiryText } from './LinkRules.jsx';
import SharedPasscodeForm from './SharedPasscodeForm.jsx';
test('says when a link expires and whether it has a passcode', () => {
const now = Date.UTC(2026, 9, 6);
expect(expiryText(null, now)).toBe('Never expires');
expect(expiryText(now - 1, now)).toBe('Expired');
expect(expiryText(now + 3 * 86_400_000, now)).toMatch(/3 days left/);
render(<LinkRules expiresAt={null} hasPasscode busy={false} onSave={vi.fn()} />);
expect(screen.getByText(/Needs a passcode/)).toBeInTheDocument();
});
test('only the settings you change are sent', async () => {
const onSave = vi.fn(async () => true);
const user = userEvent.setup();
render(<LinkRules expiresAt={null} hasPasscode={false} busy={false} onSave={onSave} />);
const save = screen.getByRole('button', { name: 'Save settings' });
expect(save).toBeDisabled();
await user.selectOptions(screen.getByLabelText('Stop working'), '7');
await user.click(save);
expect(onSave).toHaveBeenLastCalledWith({ expiresInDays: 7 }); // no passcode key: the passcode is left alone
await user.type(screen.getByLabelText(/Passcode/), ' moses ');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(onSave).toHaveBeenLastCalledWith({ passcode: 'moses' });
});
test('a passcode can be removed, and "Never" clears the expiry', async () => {
const onSave = vi.fn(async () => true);
const user = userEvent.setup();
render(<LinkRules expiresAt={Date.now() + 86_400_000} hasPasscode busy={false} onSave={onSave} />);
await user.click(screen.getByRole('button', { name: 'Remove passcode' }));
expect(onSave).toHaveBeenLastCalledWith({ passcode: null });
await user.selectOptions(screen.getByLabelText('Stop working'), 'Never');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(onSave).toHaveBeenLastCalledWith({ expiresInDays: null });
});
test('the viewer\'s passcode form submits what was typed and shows a wrong-passcode message', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<SharedPasscodeForm error="That passcode is not right." onSubmit={onSubmit} />);
expect(screen.getByRole('alert')).toHaveTextContent(/not right/);
expect(screen.getByRole('button', { name: 'Open' })).toBeDisabled();
await user.type(screen.getByLabelText('Passcode'), 'moses');
await user.click(screen.getByRole('button', { name: 'Open' }));
expect(onSubmit).toHaveBeenCalledWith('moses');
});
+137
View File
@@ -0,0 +1,137 @@
// Present mode: the script or the outline full-screen in large type, with a timer. The script scrolls by
// itself at a speed you set (a teleprompter), and the outline moves one point at a time with the minutes you
// planned beside the clock. The screen is kept awake while it is open. Esc, or Close, leaves.
import { useCallback, useEffect, useRef, useState } from 'react';
import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js';
const SIZES = [20, 24, 28, 34, 40, 48, 60, 72];
const btn = 'rounded-lg border border-current/30 px-3 py-1.5 text-sm hover:bg-white/10 disabled:opacity-40';
export default function PresentMode({ title, subtitle = '', mode, script = '', tree = [], onClose }) {
const [dark, setDark] = useState(true);
const [size, setSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-size')) || 34)); } catch { return 3; } });
const [elapsed, setElapsed] = useState(0);
const [running, setRunning] = useState(false);
const [scrolling, setScrolling] = useState(false);
const [speed, setSpeed] = useState(30); // pixels per second
const [index, setIndex] = useState(0);
const scroller = useRef(null);
const steps = mode === 'outline' ? outlineSteps(tree) : [];
const step = steps[index];
const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null;
// the clock
useEffect(() => {
if (!running) return undefined;
const t = window.setInterval(() => setElapsed((e) => e + 1), 1000);
return () => window.clearInterval(t);
}, [running]);
// the teleprompter
useEffect(() => {
if (!scrolling || mode !== 'script') return undefined;
let last = performance.now();
let frame;
let carry = 0;
const tick = (now) => {
const el = scroller.current;
carry += ((now - last) / 1000) * speed;
last = now;
if (el && carry >= 1) { el.scrollTop += Math.floor(carry); carry -= Math.floor(carry); }
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 2) { setScrolling(false); return; }
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [scrolling, speed, mode]);
// keep the screen on
useEffect(() => {
let lock;
let gone = false;
navigator.wakeLock?.request?.('screen').then((l) => { if (gone) l.release?.(); else lock = l; }).catch(() => {});
return () => { gone = true; lock?.release?.().catch?.(() => {}); };
}, []);
const bigger = (d) => setSize((s) => {
const next = Math.min(SIZES.length - 1, Math.max(0, s + d));
try { localStorage.setItem('present-size', String(SIZES[next])); } catch { /* storage unavailable */ }
return next;
});
const go = useCallback((d) => setIndex((i) => Math.min(steps.length - 1, Math.max(0, i + d))), [steps.length]);
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.target?.tagName === 'INPUT') return;
if (mode === 'outline') {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); }
} else if (e.key === ' ') { e.preventDefault(); setScrolling((s) => !s); }
if (e.key === '+' || e.key === '=') bigger(1);
if (e.key === '-') bigger(-1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [mode, go, onClose]);
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
const fontSize = `${SIZES[size]}px`;
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
return (
<div role="dialog" aria-modal="true" aria-label={`Presenting ${title}`} className={`fixed inset-0 z-[80] flex flex-col ${colors}`}>
<div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-current/15 px-4 py-2.5">
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{title}</p>{subtitle && <p className="truncate text-xs opacity-60">{subtitle}</p>}</div>
<div className="flex items-center gap-2" role="group" aria-label="Timer">
<span className="min-w-[4.5rem] text-center font-mono text-xl tabular-nums" aria-live="off">{formatClock(elapsed)}</span>
<button type="button" onClick={() => setRunning((r) => !r)} className={btn}>{running ? 'Pause' : elapsed ? 'Resume' : 'Start'}</button>
<button type="button" onClick={() => { setElapsed(0); setRunning(false); }} disabled={!elapsed} className={btn}>Reset</button>
</div>
{pace && <span className={`text-sm ${pace.diffSeconds < -20 ? 'text-amber-400' : 'opacity-80'}`} role="status">{paceText(pace)} · plan {formatClock(pace.totalSeconds)}</span>}
<div className="flex items-center gap-1.5" role="group" aria-label="Text size">
<button type="button" onClick={() => bigger(-1)} disabled={size === 0} aria-label="Smaller text" className={btn}>A−</button>
<button type="button" onClick={() => bigger(1)} disabled={size === SIZES.length - 1} aria-label="Larger text" className={btn}>A+</button>
<button type="button" onClick={() => setDark((d) => !d)} aria-pressed={dark} className={btn}>{dark ? 'Light' : 'Dark'}</button>
</div>
{mode === 'script' && (
<div className="flex items-center gap-2" role="group" aria-label="Auto-scroll">
<button type="button" onClick={() => setScrolling((s) => !s)} aria-pressed={scrolling} className={btn}>{scrolling ? 'Stop scrolling' : 'Auto-scroll'}</button>
<label className="flex items-center gap-1.5 text-xs opacity-80">Speed
<input type="range" min="8" max="120" value={speed} onChange={(e) => setSpeed(Number(e.target.value))} aria-label="Scroll speed" />
</label>
</div>
)}
<button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button>
</div>
{mode === 'script' ? (
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8vh]" style={{ scrollBehavior: 'auto' }}>
{script.trim()
? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50vh' }} aria-hidden="true" /></div>
: <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>}
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6vh]">
{step ? (
<div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}>
<p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}</p>
<h1 className="mt-2 font-semibold"><span className="mr-3 opacity-60">{step.label}</span>{step.text || '(untitled)'}</h1>
{step.ref && <p className="mt-2 text-[0.6em] opacity-70">{step.ref}</p>}
{step.scripture?.text && <blockquote className="mt-5 border-l-4 border-current/30 pl-5 text-[0.8em] italic opacity-90">“{step.scripture.text}” <span className="not-italic opacity-60">— {step.scripture.ref}</span></blockquote>}
{step.note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{step.note}</p>}
{step.attachments.length > 0 && <p className="mt-5 text-[0.55em] opacity-60">{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}</p>}
</div>
) : <p className="mt-20 text-center text-lg opacity-70">There’s no outline to present yet. Build one on the Outline page.</p>}
</div>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-current/15 px-4 py-3">
<button type="button" onClick={() => go(-1)} disabled={index === 0} className={btn}>← Back</button>
<span className="text-xs opacity-60">{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move</span>
<button type="button" onClick={() => go(1)} disabled={index >= steps.length - 1} className={btn}>Next →</button>
</div>
</div>
)}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, afterEach } from 'vitest';
import PresentMode from './PresentMode.jsx';
afterEach(() => vi.useRealTimers());
const n = (id, text, minutes, extra = {}) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children: [], ...extra });
const tree = [n('a', 'Welcome', 2, { note: 'Say hello' }), n('b', 'The gospel', 10, { ref: 'Rom 1:16' })];
test('the outline is stepped through a point at a time, with the keyboard or the buttons', async () => {
const user = userEvent.setup();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
expect(screen.getByText('Welcome')).toBeInTheDocument();
expect(screen.getByText('Say hello')).toBeInTheDocument();
expect(screen.getByText(/Point 1 of 2/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled();
await user.keyboard('{ArrowRight}');
expect(screen.getByText('The gospel')).toBeInTheDocument();
expect(screen.getByText('Rom 1:16')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
await user.click(screen.getByRole('button', { name: /back/i }));
expect(screen.getByText('Welcome')).toBeInTheDocument();
});
test('the timer runs, pauses and resets, and tells you how you are doing against the plan', async () => {
vi.useFakeTimers();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
const group = screen.getByRole('group', { name: 'Timer' });
const click = (name) => act(() => { screen.getByRole('button', { name }).click(); });
click('Start');
act(() => { vi.advanceTimersByTime(65_000); });
expect(group).toHaveTextContent('1:05');
expect(screen.getByRole('status')).toHaveTextContent(/On pace/); // a minute in, on a 2-minute point
click('Pause');
act(() => { vi.advanceTimersByTime(10_000); });
expect(group).toHaveTextContent('1:05'); // paused
click('Reset');
expect(group).toHaveTextContent('0:00');
});
test('the text size can be changed and the choice is remembered', async () => {
const user = userEvent.setup();
localStorage.clear();
render(<PresentMode mode="script" title="Romans 8" script="In the beginning" onClose={() => {}} />);
const text = screen.getByText('In the beginning');
expect(text.style.fontSize).toBe('34px');
await user.click(screen.getByRole('button', { name: 'Larger text' }));
expect(text.style.fontSize).toBe('40px');
expect(localStorage.getItem('present-size')).toBe('40');
});
test('Escape and Close both leave, and an empty script says what to do', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(<PresentMode mode="script" title="T" script=" " onClose={onClose} />);
expect(screen.getByText(/no script to present yet/i)).toBeInTheDocument();
await user.keyboard('{Escape}');
await user.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(2);
});
+60
View File
@@ -0,0 +1,60 @@
// "Your reading": how much of the Bible you have read (every chapter you spent time on in the Reader, plus the
// ones you finished in reading plans), a tile for each book that fills as you read it, and a calendar of the
// days you read in the last six months.
import { useMemo } from 'react';
import { getPlanSchedule } from '../utils/readingPlans.js';
import { heatmapWeeks, planChapterKeys, progressSummary } from '../utils/readingHistory.js';
const pct = (n) => `${Math.round(n * 100)}%`;
export default function ReadingProgress({ history, planProgress = [], activity = [], onOpenBook }) {
const summary = useMemo(() => progressSummary(history, planChapterKeys(planProgress, getPlanSchedule)), [history, planProgress]);
const days = useMemo(() => new Set([...(history.days ?? []), ...activity]), [history.days, activity]);
const weeks = useMemo(() => heatmapWeeks(days), [days]);
const daysInView = weeks.flat().filter((d) => d.on).length;
const group = (list) => (
<div className="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-1.5">
{list.map((b) => (
<button key={b.abbrev} type="button" onClick={() => onOpenBook?.(b.abbrev)} title={`${b.name}: ${b.read} of ${b.total} chapters read`}
aria-label={`${b.name}, ${b.read} of ${b.total} chapters read`}
className="relative overflow-hidden rounded-lg border border-[var(--r-line)] px-2 py-1.5 text-left text-xs hover:border-[var(--r-accent)]">
<span aria-hidden="true" className="absolute inset-y-0 left-0 bg-[var(--r-accent)] opacity-25" style={{ width: pct(b.pct) }} />
<span className="relative block truncate font-medium">{b.name}</span>
<span className="relative block text-[10px] text-[var(--r-sub)]">{b.read}/{b.total}</span>
</button>
))}
</div>
);
return (
<section aria-label="Your reading" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-sm font-medium">Your reading</h2>
<p className="text-xs text-[var(--r-sub)]">
<span className="text-base font-medium text-[var(--r-text)]">{pct(summary.pct)}</span> of the Bible · {summary.read} of {summary.total} chapters
</p>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[var(--r-line)]" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(summary.pct * 100)} aria-label="Bible read">
<div className="h-full bg-[var(--r-accent)]" style={{ width: pct(summary.pct) }} />
</div>
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Old Testament {summary.ot.read}/{summary.ot.total} · New Testament {summary.nt.read}/{summary.nt.total}. A chapter counts once you’ve had it open for a bit in the Reader, or finished it in a plan.</p>
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Old Testament</h3>
{group(summary.books.filter((b) => b.testament === 'ot'))}
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">New Testament</h3>
{group(summary.books.filter((b) => b.testament === 'nt'))}
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Days you read · {daysInView} in the last 6 months</h3>
<div className="flex gap-[3px] overflow-x-auto pb-1" role="img" aria-label={`${daysInView} days read in the last six months`}>
{weeks.map((week) => (
<div key={week[0].date} className="flex flex-col gap-[3px]">
{week.map((d) => (
<span key={d.date} title={d.future ? '' : `${d.date}${d.on ? ': read' : ''}`}
className={`h-3 w-3 rounded-[3px] ${d.future ? 'opacity-0' : d.on ? 'bg-[var(--r-accent)]' : 'bg-[var(--r-line)]'}`} />
))}
</div>
))}
</div>
</section>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
import ReadingProgress from './ReadingProgress.jsx';
const history = { chapters: { 'GEN.1': { n: 1, last: '2026-10-01' }, 'GEN.2': { n: 2, last: '2026-10-02' }, 'JHN.3': { n: 1, last: '2026-10-03' } }, days: ['2026-10-01', '2026-10-02'] };
test('shows how much of the Bible is read, and each book\'s progress', () => {
render(<ReadingProgress history={history} planProgress={[]} activity={[]} />);
expect(screen.getByText(/3 of 1189 chapters/)).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: 'Bible read' })).toHaveAttribute('aria-valuenow', '0');
expect(screen.getByRole('button', { name: 'Genesis, 2 of 50 chapters read' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'John, 1 of 21 chapters read' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Revelation, 0 of 22 chapters read' })).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(66);
});
test('days from plans count on the calendar too, and a book opens in the Reader', async () => {
const onOpenBook = vi.fn();
render(<ReadingProgress history={history} planProgress={[]} activity={['2026-10-05']} onOpenBook={onOpenBook} />);
expect(screen.getByRole('img', { name: /3 days read in the last six months/ })).toBeInTheDocument();
await userEvent.setup().click(screen.getByRole('button', { name: /^Genesis/ }));
expect(onOpenBook).toHaveBeenCalledWith('GEN');
});
test('an empty history is fine', () => {
render(<ReadingProgress history={{ chapters: {}, days: [] }} />);
expect(screen.getByText(/0 of 1189 chapters/)).toBeInTheDocument();
expect(within(screen.getByRole('img')).queryAllByTitle(/: read/)).toHaveLength(0);
});
+64
View File
@@ -0,0 +1,64 @@
// Export → Schedule: the study's sessions (one per chunk) as a calendar file, starting on a date you pick.
import { useState } from 'react';
import { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildIcs, downloadIcs, sessionDates } from '../utils/icsFile.js';
const field = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const isoDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
// The study's sessions in order: { title, description } for each chunk.
export function sessionsOf(project) {
const list = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
for (const chunk of chapter.chunks ?? []) {
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
const n = list.length + 1;
const label = `Session ${(chunk.episodeNumber ?? '').toString().trim() || n}`;
const name = (project.series ?? '').trim() || project.title;
list.push({ title: `${name} — ${label}: ${(chunk.episodeTitle ?? '').trim() || ref}`, description: `${project.title}\n${ref}` });
}
});
return list;
}
export default function SessionSchedule({ project }) {
const sessions = sessionsOf(project);
const [start, setStart] = useState(() => isoDay(new Date()));
const [every, setEvery] = useState('7');
const [time, setTime] = useState('');
const [minutes, setMinutes] = useState('60');
if (sessions.length === 0) return null;
const download = () => {
const [y, m, d] = start.split('-').map(Number);
const dates = sessionDates(new Date(y, m - 1, d), sessions.length, Number(every));
const events = sessions.map((s, i) => ({ ...s, date: dates[i], time: time || undefined, minutes: Number(minutes) || 60 }));
downloadIcs(buildIcs(events, { name: (project.series ?? '').trim() || project.title }), `${(project.title || 'study').replace(/[^\w]+/g, '-').toLowerCase()}-schedule.ics`);
};
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Schedule">
<h2 className="text-sm font-medium">Schedule</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Put the {sessions.length} session{sessions.length === 1 ? '' : 's'} on your calendar: one per chunk, in order, using each chunk’s session number and title.</p>
<div className="flex flex-wrap items-end gap-3">
<label className="text-xs text-[var(--r-sub)]">First session
<input type="date" value={start} onChange={(e) => setStart(e.target.value)} className={`${field} mt-1 block`} />
</label>
<label className="text-xs text-[var(--r-sub)]">Then every
<select value={every} onChange={(e) => setEvery(e.target.value)} className={`${field} mt-1 block`}>
<option value="1">day</option><option value="7">week</option><option value="14">2 weeks</option><option value="30">30 days</option>
</select>
</label>
<label className="text-xs text-[var(--r-sub)]">At (optional)
<input type="time" value={time} onChange={(e) => setTime(e.target.value)} className={`${field} mt-1 block`} />
</label>
{time && (
<label className="text-xs text-[var(--r-sub)]">Minutes
<input type="number" min="5" max="480" value={minutes} onChange={(e) => setMinutes(e.target.value)} className={`${field} mt-1 block w-20`} />
</label>
)}
<button type="button" onClick={download} disabled={!start} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50">Download calendar file (.ics)</button>
</div>
</section>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
vi.mock('../utils/icsFile.js', async (orig) => ({ ...(await orig()), downloadIcs: vi.fn() }));
import SessionSchedule, { sessionsOf } from './SessionSchedule.jsx';
import { downloadIcs } from '../utils/icsFile.js';
const chunk = (id, s, e, x = {}) => ({ id, startVerse: s, endVerse: e, ...x });
const project = {
title: 'Romans 8', series: 'Romans', chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [], chunks: [
chunk('a', 1, 11, { episodeNumber: '4', episodeTitle: 'No condemnation' }), chunk('b', 12, 17),
] }],
};
test('each chunk is a session named for the series, its number and title (or passage)', () => {
const s = sessionsOf(project);
expect(s[0].title).toBe('Romans — Session 4: No condemnation');
expect(s[1].title).toMatch(/^Romans — Session 2: Romans 8:12/);
});
test('the calendar file has one event per session, spaced as asked', async () => {
const user = userEvent.setup();
render(<SessionSchedule project={project} />);
await user.clear(screen.getByLabelText('First session'));
await user.type(screen.getByLabelText('First session'), '2026-10-11');
await user.click(screen.getByRole('button', { name: /download calendar file/i }));
const [text, name] = downloadIcs.mock.calls[0];
expect(name).toBe('romans-8-schedule.ics');
expect(text.match(/BEGIN:VEVENT/g)).toHaveLength(2);
expect(text).toContain('DTSTART;VALUE=DATE:20261011');
expect(text).toContain('DTSTART;VALUE=DATE:20261018'); // a week later
});
test('a study with no chunks shows nothing', () => {
const { container } = render(<SessionSchedule project={{ title: 'x', chapters: [] }} />);
expect(container).toBeEmptyDOMElement();
});
+3 -1
View File
@@ -1,5 +1,6 @@
// Share an outline: a read-only link anyone can open (no account), or the plain text.
import { useState } from 'react';
import LinkRules from './LinkRules.jsx';
import Sheet from './reader/Sheet.jsx';
import { useToast } from './Toasts.jsx';
import { useOutlineShareLink } from '../hooks/useOutlineShareLink.js';
@@ -49,10 +50,11 @@ export default function ShareOutlineSheet({ open, onClose, projectId, outline })
<button type="button" onClick={shareLink} className={btn}>Share link…</button>
<button type="button" onClick={link.turnOff} disabled={link.busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{link.busy ? 'Turning off…' : 'Turn off link'}</button>
</div>
<LinkRules expiresAt={link.expiresAt} hasPasscode={link.hasPasscode} busy={link.busy} onSave={async (options) => { await link.create(options); return true; }} />
<p className="text-[11px] text-[var(--r-sub)]">People see the version that last synced to your account, and it updates as you edit. Turning the link off stops it working right away.</p>
</div>
) : (
<button type="button" onClick={link.create} disabled={link.busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{link.busy ? 'Creating…' : 'Create read-only link'}</button>
<button type="button" onClick={() => link.create()} disabled={link.busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{link.busy ? 'Creating…' : 'Create read-only link'}</button>
))}
{link.error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{link.error}</p>}
+17 -7
View File
@@ -1,8 +1,9 @@
// What someone sees when they open a read-only outline link: a calm page with the outline, a switcher
// when there is more than one, and Print / PDF. Nothing is editable.
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { getSharedOutline } from '../syncService.js';
import { themeVars } from './reader/readerThemes.js';
import SharedPasscodeForm from './SharedPasscodeForm.jsx';
import OutlineDocument from './OutlineDocument.jsx';
import { printOutline } from '../utils/outlineExport.js';
import { flatten } from '../utils/outline.js';
@@ -17,18 +18,27 @@ export default function SharedOutlineView({ token }) {
const [data, setData] = useState(null);
const [error, setError] = useState('');
const [scope, setScope] = useState('study');
const [needsPasscode, setNeedsPasscode] = useState(false);
const [passcodeError, setPasscodeError] = useState('');
const [checking, setChecking] = useState(false);
useEffect(() => {
let active = true;
getSharedOutline(token).then((result) => {
if (!active) return;
if (result.ok) { setData(result.data); setScope(result.data.outlines[0]?.scope ?? 'study'); } else setError(result.error ?? 'This outline link is invalid or has been turned off.');
const load = useCallback((passcode) => {
setChecking(true);
return getSharedOutline(token, passcode).then((result) => {
setChecking(false);
if (result.ok) { setData(result.data); setNeedsPasscode(false); setScope(result.data.outlines[0]?.scope ?? 'study'); return; }
if (result.data?.needsPasscode) { setNeedsPasscode(true); setPasscodeError(passcode ? result.error : ''); return; }
setNeedsPasscode(false);
setError(result.error ?? 'This outline link is invalid or has been turned off.');
});
return () => { active = false; };
}, [token]);
useEffect(() => { load(); }, [load]);
useEffect(() => { if (data?.title) document.title = `${data.title} — Outline`; }, [data]);
if (needsPasscode) return <Frame><SharedPasscodeForm error={passcodeError} busy={checking} onSubmit={load} /></Frame>;
if (error) {
return (
<Frame>
+20
View File
@@ -0,0 +1,20 @@
// What someone sees when a shared link needs a passcode.
import { useState } from 'react';
export default function SharedPasscodeForm({ error, busy, onSubmit, dark = false }) {
const [value, setValue] = useState('');
return (
<div className={`flex min-h-dvh items-center justify-center px-4 ${dark ? 'bg-slate-900' : 'bg-[var(--r-bg)] text-[var(--r-text)]'}`}>
<form onSubmit={(e) => { e.preventDefault(); if (value.trim()) onSubmit(value.trim()); }}
className="w-full max-w-sm rounded-3xl border border-black/10 bg-white p-8 text-center text-slate-900 shadow-lg">
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
<h1 className="mt-4 text-lg font-medium">This link has a passcode</h1>
<p className="mt-1 text-sm text-slate-500">Enter the passcode the person who shared it gave you.</p>
<input type="password" autoFocus autoComplete="off" aria-label="Passcode" value={value} onChange={(e) => setValue(e.target.value)}
className="mt-5 block w-full rounded-xl border border-slate-300 px-3 py-2.5 text-center text-sm" />
{error && <p role="alert" className="mt-3 text-sm text-rose-600">{error}</p>}
<button type="submit" disabled={busy || !value.trim()} className="mt-5 w-full rounded-full bg-slate-900 px-4 py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50">{busy ? 'Checking…' : 'Open'}</button>
</form>
</div>
);
}
+18
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx';
import { buildMarksDocxBlob, buildMarksModel, downloadMarksFile, marksToMarkdown } from '../../utils/marksExport.js';
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
@@ -20,6 +21,15 @@ export default function MarksSheet({
const shown = filter ? highlights.filter((h) => h.color === filter) : highlights;
// Colours in use that aren't one of the picker's (older data) still get listed
const total = highlights.length + bookmarks.length;
const [exportError, setExportError] = useState('');
const exportAs = async (kind) => {
setExportError('');
const model = buildMarksModel({ highlights, bookmarks, colorLabels });
try {
if (kind === 'md') downloadMarksFile(new Blob([marksToMarkdown(model)], { type: 'text/markdown;charset=utf-8' }), 'highlights-and-bookmarks.md');
else downloadMarksFile(await buildMarksDocxBlob(model), 'highlights-and-bookmarks.docx');
} catch { setExportError('Couldn’t make the file. Try again.'); }
};
return (
<Sheet open={open} onClose={onClose} label="Bookmarks and highlights" title={`Bookmarks & highlights${total ? ` (${total})` : ''}`}>
@@ -91,6 +101,14 @@ export default function MarksSheet({
</div>
))
)}
{total > 0 && (
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-[var(--r-line)] pt-3" role="group" aria-label="Export your marks">
<span className="text-xs text-[var(--r-sub)]">Take them with you</span>
<button type="button" onClick={() => exportAs('md')} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Markdown</button>
<button type="button" onClick={() => exportAs('docx')} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Word (.docx)</button>
{exportError && <span role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{exportError}</span>}
</div>
)}
</Sheet>
);
}
@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
vi.mock('../../utils/marksExport.js', async (orig) => ({ ...(await orig()), downloadMarksFile: vi.fn() }));
import MarksSheet from './MarksSheet.jsx';
import { downloadMarksFile } from '../../utils/marksExport.js';
const base = { open: true, onClose: () => {}, colorLabels: { '#bbf7d0': 'Promises' }, onSetLabel: () => {}, onJump: () => {}, onRemoveBookmark: () => {}, onRemoveHighlight: () => {} };
const highlights = [{ id: 'h', color: '#bbf7d0', book: 'JHN', bookName: 'John', bookIndex: 42, chapter: 3, verse: 16, startOffset: 0, endOffset: 5, text: 'For God' }];
test('highlights and bookmarks can be exported as Markdown, named by your colour names', async () => {
const user = userEvent.setup();
render(<MarksSheet {...base} bookmarks={[]} highlights={highlights} />);
await user.click(screen.getByRole('button', { name: 'Markdown' }));
const [blob, name] = downloadMarksFile.mock.calls[0];
expect(name).toBe('highlights-and-bookmarks.md');
expect(await blob.text()).toContain('## Promises (green)');
});
test('Word export is offered too, and nothing to export means no buttons', async () => {
const user = userEvent.setup();
const { rerender } = render(<MarksSheet {...base} bookmarks={[]} highlights={highlights} />);
await user.click(screen.getByRole('button', { name: 'Word (.docx)' }));
await vi.waitFor(() => expect(downloadMarksFile).toHaveBeenCalledTimes(2));
expect(downloadMarksFile.mock.calls[1][1]).toBe('highlights-and-bookmarks.docx');
rerender(<MarksSheet {...base} bookmarks={[]} highlights={[]} />);
expect(screen.queryByRole('group', { name: /export your marks/i })).toBeNull();
});
+33 -2
View File
@@ -1,7 +1,8 @@
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
// area for background notes and tags so the page stays quiet.
import { useEffect, useState } from 'react';
import { templatePrompt } from '../../utils/studyTemplates.js';
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
const SECTIONS = [
{ field: 'observation', label: 'Observe', hint: 'What does the text say?',
@@ -15,11 +16,13 @@ const SECTIONS = [
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
export default function NotesPane({ chunk, updateChunk, allTags, templateId }) {
export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify }) {
const [open, setOpen] = useState(() => {
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
});
const [more, setMore] = useState(false);
const [tplName, setTplName] = useState('');
const [tplOpen, setTplOpen] = useState(false);
const [tagInput, setTagInput] = useState('');
useEffect(() => { setTagInput(''); }, [chunk.id]);
useEffect(() => { try { localStorage.setItem('studyNotesOpen', open || ''); } catch { /* storage unavailable */ } }, [open]);
@@ -53,6 +56,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId }) {
<div className="px-4 pb-4">
<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} />
<div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
</div>
)}
</div>
@@ -71,7 +75,34 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId }) {
Background / general notes
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
</label>
<label className="block text-xs text-[var(--r-sub)]">
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
</label>
{saveTemplate && (
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
{!tplOpen ? (
<button type="button" onClick={() => setTplOpen(true)} className="text-xs text-[var(--r-accent)] hover:underline">Save this chunk’s notes as a template…</button>
) : (
<div>
<p className="mb-2 text-xs text-[var(--r-sub)]">What’s written in this chunk’s note boxes becomes the starting text of each new chunk in studies made from the template, along with this study’s prompts.</p>
<div className="flex flex-wrap items-center gap-2">
<input type="text" aria-label="Template name" value={tplName} onChange={(e) => setTplName(e.target.value)} maxLength={40} placeholder="Template name"
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm" />
<button type="button" onClick={() => {
const made = templateFromChunk({ name: tplName, chunk, template: templateId });
if (!made) { notify?.('Give it a name, and write something in a note box first.', 2600); return; }
saveTemplate(made); setTplName(''); setTplOpen(false); notify?.(`Saved the template “${made.name}”. Pick it when you start a new study.`, 3200);
}} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Save template</button>
<button type="button" onClick={() => setTplOpen(false)} className="text-xs text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</div>
)}
</div>
)}
<div>
<p className="mb-1.5 text-xs text-[var(--r-sub)]">Tags</p>
<div className="flex flex-wrap items-center gap-1.5">
+15 -2
View File
@@ -165,6 +165,16 @@ export function useBibleReader(currentPage, translationOverride = null) {
return next;
});
};
// Study templates you saved from your own notes; they follow the account like the other preferences.
const [userTemplates, setUserTemplatesState] = useState(() => {
try { const v = JSON.parse(localStorage.getItem('study-templates') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
});
const setUserTemplates = (next) => {
setUserTemplatesState(next);
try { localStorage.setItem('study-templates', JSON.stringify(next)); } catch {}
};
const saveUserTemplate = (template) => setUserTemplates([...userTemplates, template].slice(-12));
const deleteUserTemplate = (id) => setUserTemplates(userTemplates.filter((t) => t.id !== id));
// Debounce timer ref so rapid stroke updates don't flood the server
const inkSaveTimerRef = useRef({});
@@ -523,7 +533,8 @@ export function useBibleReader(currentPage, translationOverride = null) {
// Signing out clears this account's ink from the device.
const stopInkSync = useCallback(() => {
Object.keys(inkSaveTimerRef.current).forEach((k) => clearTimeout(inkSaveTimerRef.current[k]));
['readerInkByPage', 'readerInkDirty'].forEach((k) => { try { localStorage.removeItem(k); } catch {} });
['readerInkByPage', 'readerInkDirty', 'study-templates'].forEach((k) => { try { localStorage.removeItem(k); } catch {} });
setUserTemplatesState([]);
setReaderInkByPage({});
}, []);
@@ -539,7 +550,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
planTranslation: planTranslationPref, startPage: startPagePref,
position: { book: readerBookAbbrev, chapter: readerChapter },
colorLabels: readerColorLabels,
colorLabels: readerColorLabels, studyTemplates: userTemplates,
},
apply: (key, v) => {
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
@@ -549,6 +560,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', v); } catch {} }
else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} }
else if (key === 'colorLabels') { setReaderColorLabelsState(v); try { localStorage.setItem('reader-color-labels', JSON.stringify(v)); } catch {} }
else if (key === 'studyTemplates') { setUserTemplatesState(v); try { localStorage.setItem('study-templates', JSON.stringify(v)); } catch {} }
else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
},
canApplyPosition: () => pageRef.current !== 'reader',
@@ -597,6 +609,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
removeTextHighlight,
restoreTextHighlight,
readerColorLabels, setReaderColorLabel,
userTemplates, saveUserTemplate, deleteUserTemplate,
bibleIndexStatusFor,
loadBibleIndex,
_bibleIndexCacheRef,
+11 -6
View File
@@ -102,7 +102,7 @@ function initialChapterIndex(project) {
return index >= 0 ? index : 0;
}
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) {
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage, userTemplates = [] }) {
const { toast } = useToast();
const [setup, setSetup] = useState(() => initialSetupForm(project));
const [titleEdited, setTitleEdited] = useState(false);
@@ -138,7 +138,10 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
const activeChapter = project?.chapters[activeChapterIndex] ?? null;
const handleSetupField = (field, value) => {
if (field === 'book') {
if (field === 'template') {
// a template of your own is copied into the study, so it keeps working wherever the study goes
setSetup((current) => ({ ...current, template: value, templateDef: userTemplates.find((t) => t.id === value) }));
} else if (field === 'book') {
const selected = bookOptions.find((book) => book.abbrev === value) ?? bookOptions[0];
setSetup((current) => ({
...current,
@@ -195,6 +198,8 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
title: setup.title,
translation: setup.translation,
templateId: setup.template ?? 'blank',
...(setup.templateDef ? { templateDef: setup.templateDef } : {}), // a copy of your own template
lastEdited: Date.now(),
selectedChunkId: null,
chapters: [
@@ -237,7 +242,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
);
if (overlap) return ch;
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateId);
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateDef ?? current.templateId);
return { ...ch, chunks: [...ch.chunks, newChunk] };
});
// Find the newly added chunk id
@@ -286,7 +291,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
skippedCount += 1;
return;
}
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateId);
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateDef ?? current.templateId);
existing.add(key);
newChunks.push(newChunk);
addedCount += 1;
@@ -577,12 +582,12 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
if (i >= 0) {
const c = chunks[i];
chunks[i] = { ...c, endVerse: n, spilloverEndVerse: null };
chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null, current.templateId));
chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null, current.templateDef ?? current.templateId));
return { ...ch, chunks };
}
if (chunks.some((c) => c.startVerse <= n && n <= c.endVerse)) return ch; // already a boundary
const start = Math.max(0, ...chunks.filter((c) => c.endVerse < n).map((c) => c.endVerse)) + 1;
const created = blankChunk(start, n, null, current.templateId);
const created = blankChunk(start, n, null, current.templateDef ?? current.templateId);
selected = created.id;
const at = chunks.findIndex((c) => c.startVerse > n);
chunks.splice(at < 0 ? chunks.length : at, 0, created);
+9 -6
View File
@@ -5,6 +5,8 @@ import { disableOutlineSharing, enableOutlineSharing, getOutlineShareStatus } fr
export function useOutlineShareLink(projectId, enabled = true) {
const [token, setToken] = useState(null);
const [expiresAt, setExpiresAt] = useState(null);
const [hasPasscode, setHasPasscode] = useState(false);
const [state, setState] = useState('loading'); // 'loading' | 'ready' | 'unavailable' (no account / server)
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
@@ -17,19 +19,20 @@ export function useOutlineShareLink(projectId, enabled = true) {
setState('loading');
getOutlineShareStatus(projectId).then((result) => {
if (!active) return;
if (result.ok) { setToken(result.data.shareToken); setState('ready'); } else setState('unavailable');
if (result.ok) { setToken(result.data.shareToken); setExpiresAt(result.data.expiresAt ?? null); setHasPasscode(!!result.data.hasPasscode); setState('ready'); } else setState('unavailable');
});
return () => { active = false; };
}, [projectId, enabled]);
const url = token ? `${window.location.origin}${window.location.pathname}?outline=${token}` : '';
const create = async () => {
// Creates the link, or changes its rules: { expiresInDays?: number | null, passcode?: string | null }
const create = async (options = {}) => {
setBusy(true);
setError('');
const result = await enableOutlineSharing(projectId);
const result = await enableOutlineSharing(projectId, options);
setBusy(false);
if (result.ok) setToken(result.data.shareToken);
if (result.ok) { setToken(result.data.shareToken); setExpiresAt(result.data.expiresAt ?? null); setHasPasscode(!!result.data.hasPasscode); }
else setError(result.status === 404 ? 'Your study hasn’t synced to your account yet. Wait a moment and try again.' : 'Couldn’t create the link. Check your connection and try again.');
};
@@ -37,8 +40,8 @@ export function useOutlineShareLink(projectId, enabled = true) {
setBusy(true);
const result = await disableOutlineSharing(projectId);
setBusy(false);
if (result.ok) setToken(null); else setError('Couldn’t turn the link off. Try again.');
if (result.ok) { setToken(null); setExpiresAt(null); setHasPasscode(false); } else setError('Couldn’t turn the link off. Try again.');
};
return { token, url, state, busy, error, create, turnOff };
return { token, url, expiresAt, hasPasscode, state, busy, error, create, turnOff };
}
+2 -2
View File
@@ -5,7 +5,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { getRemotePrefs, saveRemotePrefs } from '../syncService.js';
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position', 'colorLabels'];
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position', 'colorLabels', 'studyTemplates'];
const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device }
const PUSH_DELAY_MS = 1000;
const FOCUS_PULL_MIN_MS = 30_000;
@@ -58,7 +58,7 @@ export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}
}, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, values.colorLabels, push]); // eslint-disable-line react-hooks/exhaustive-deps
}, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, values.colorLabels, values.studyTemplates, push]); // eslint-disable-line react-hooks/exhaustive-deps
const pullServerPrefs = useCallback(async () => {
enabledRef.current = true;
+56
View File
@@ -29,6 +29,9 @@ export function useProjectLibrary({
const pendingDeletes = useRef(new Map()); // id -> timer
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
// Studies another account invited you to edit: id -> the owner's email (shown on the card)
const [sharedStudies, setSharedStudies] = useState({});
const sharedFrom = (list) => Object.fromEntries(list.filter((e) => e.shared).map((e) => [e.id, e.ownerEmail ?? '']));
// Studies saved here that the server hasn't accepted yet (saved offline, or the tab closed mid-sync).
// A study another device changed meanwhile is left alone; opening it offers the keep-mine/keep-theirs choice.
@@ -76,6 +79,7 @@ export function useProjectLibrary({
const gone = new Set(pendingDeleteIds.list());
await retryPendingDeletes();
const serverList = result.data.filter((e) => !gone.has(e.id));
setSharedStudies(sharedFrom(serverList));
const localMap = new Map(localIndex.map((e) => [e.id, e]));
// What this device has seen of each study on the server: a save made on top of anything older is
// checked against it, so a newer copy from another device is never overwritten silently.
@@ -127,6 +131,29 @@ export function useProjectLibrary({
return () => { cancelled = true; };
}, [authUser]);
// Another device (or a co-teacher) may have saved a study since you opened this page: look again when you come
// back to the tab, and every couple of minutes while it's open. A newer copy is offered, never forced on you.
const refreshStale = async () => {
const result = await listRemoteProjects();
if (!result.ok) return;
const local = new Map(loadProjectIndex().map((e) => [e.id, e]));
const unsynced = new Set(unsyncedStudies.list());
setSharedStudies(sharedFrom(result.data));
setStaleLocalProjects(result.data.filter((e) => {
const mine = local.get(e.id);
return mine && (e.lastEdited ?? 0) > (mine.lastEdited ?? 0) && !unsynced.has(e.id);
}));
};
useEffect(() => {
if (!authUser) return undefined;
let last = Date.now();
const maybe = () => { if (document.visibilityState === 'visible' && Date.now() - last > 30_000) { last = Date.now(); refreshStale(); } };
document.addEventListener('visibilitychange', maybe);
const timer = window.setInterval(maybe, 120_000);
return () => { document.removeEventListener('visibilitychange', maybe); window.clearInterval(timer); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authUser]);
// Back online: send what the server missed.
useEffect(() => {
const online = () => { if (authUser) { retryPendingDeletes(); pushPending(); } };
@@ -165,6 +192,27 @@ export function useProjectLibrary({
}
};
// Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name).
const setProjectSeries = (id, series) => {
const loaded = loadProjectById(id);
if (!loaded) return;
const name = String(series ?? '').trim().slice(0, 80);
if ((loaded.series ?? '') === name) return;
const updated = { ...loaded, series: name, lastEdited: Date.now() };
saveProjectToStorage(updated);
unsyncedStudies.add(id);
setProjectIndex(loadProjectIndex());
if (project?.id === id) setProject((current) => (current ? { ...current, series: name } : current));
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
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
const finishDelete = (id) => {
@@ -190,6 +238,11 @@ export function useProjectLibrary({
pendingDeleteIds.add(id);
setProjectIndex(loadProjectIndex());
pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000));
if (sharedStudies[id] !== undefined) {
// A study someone else owns: this only removes you from it (they keep it). A new invite is needed to rejoin.
toast(`You left “${loaded.title}”. Ask for a new invite if you want back in.`);
return;
}
toast(`Deleted “${loaded.title}”`, {
action: {
label: 'Undo',
@@ -218,10 +271,13 @@ export function useProjectLibrary({
};
return {
sharedStudies,
refreshLibrary: refreshStale,
staleLocalProjects,
autoRestoredCount,
resumeProject,
renameProjectInStorage,
setProjectSeries,
deleteProject,
pullLatestFromServer,
};
+72
View File
@@ -0,0 +1,72 @@
// Remembers which chapters you have read in the Reader, and keeps that in step with your account.
// A chapter counts as read after you have had it open for a little while (not just flicked past).
// It is saved on this device, and merged with the account's copy (never overwriting it) when you sign in
// and a moment after each new chapter, so two devices both add to the same history.
import { useCallback, useEffect, useRef, useState } from 'react';
import { getRemoteHistory, saveRemoteHistory } from '../syncService.js';
import { emptyHistory, historyEqual, mergeHistory, normalizeHistory, recordRead } from '../utils/readingHistory.js';
import { localDateString } from '../utils/readingPlans.js';
const KEY = 'reading-history';
export const DWELL_MS = 15_000;
const PUSH_DELAY_MS = 3000;
const load = () => { try { return normalizeHistory(JSON.parse(localStorage.getItem(KEY) || 'null')); } catch { return emptyHistory(); } };
const save = (h) => { try { localStorage.setItem(KEY, JSON.stringify(h)); } catch { /* storage unavailable */ } };
export function useReadingHistory({ active, book, chapter }) {
const [history, setHistory] = useState(load);
const historyRef = useRef(history);
historyRef.current = history;
const enabledRef = useRef(false);
const timerRef = useRef(null);
const apply = useCallback((next) => {
if (historyEqual(next, historyRef.current)) return;
historyRef.current = next;
save(next);
setHistory(next);
}, []);
const push = useCallback(async () => {
window.clearTimeout(timerRef.current);
if (!enabledRef.current) return;
const result = await saveRemoteHistory(historyRef.current);
if (result.ok) apply(mergeHistory(historyRef.current, result.data));
}, [apply]);
// A chapter on screen long enough counts as read.
useEffect(() => {
if (!active || !book || !chapter) return undefined;
const t = window.setTimeout(() => {
if (document.visibilityState === 'hidden') return;
const next = recordRead(historyRef.current, book, chapter, localDateString());
if (next === historyRef.current) return;
apply(next);
if (enabledRef.current) { window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(push, PUSH_DELAY_MS); }
}, DWELL_MS);
return () => window.clearTimeout(t);
}, [active, book, chapter, apply, push]);
// Signing in: combine this device's history with the account's, and send back anything the account lacks.
const pull = useCallback(async () => {
enabledRef.current = true;
const result = await getRemoteHistory();
if (!result.ok) return;
const merged = mergeHistory(historyRef.current, result.data);
apply(merged);
if (!historyEqual(merged, result.data)) await push();
}, [apply, push]);
// Signing out clears this device's copy, so the next person starts clean.
const stop = useCallback(() => {
enabledRef.current = false;
window.clearTimeout(timerRef.current);
try { localStorage.removeItem(KEY); } catch { /* storage unavailable */ }
historyRef.current = emptyHistory();
setHistory(emptyHistory());
}, []);
useEffect(() => () => window.clearTimeout(timerRef.current), []);
return { readingHistory: history, pullReadingHistory: pull, stopReadingHistory: stop };
}
+56
View File
@@ -0,0 +1,56 @@
import { renderHook, act } from '@testing-library/react';
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
vi.mock('../syncService.js', () => ({ getRemoteHistory: vi.fn(), saveRemoteHistory: vi.fn() }));
import { useReadingHistory, DWELL_MS } from './useReadingHistory.js';
import { getRemoteHistory, saveRemoteHistory } from '../syncService.js';
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
vi.useFakeTimers();
getRemoteHistory.mockResolvedValue({ ok: true, data: { chapters: { 'ROM.8': { n: 1, last: '2026-09-01' } }, days: ['2026-09-01'] } });
saveRemoteHistory.mockImplementation(async (h) => ({ ok: true, data: h }));
});
afterEach(() => vi.useRealTimers());
const setup = (props) => renderHook((p) => useReadingHistory(p), { initialProps: { active: true, book: 'JHN', chapter: 3, ...props } });
test('a chapter counts as read only after it has been open a while', () => {
const { result } = setup();
act(() => { vi.advanceTimersByTime(DWELL_MS - 1000); });
expect(Object.keys(result.current.readingHistory.chapters)).toEqual([]);
act(() => { vi.advanceTimersByTime(1500); });
expect(Object.keys(result.current.readingHistory.chapters)).toEqual(['JHN.3']);
expect(JSON.parse(localStorage.getItem('reading-history')).chapters['JHN.3'].n).toBe(1);
});
test('flicking past a chapter, or not being in the Reader, counts for nothing', () => {
const { result, rerender } = setup();
act(() => { vi.advanceTimersByTime(5000); });
rerender({ active: true, book: 'JHN', chapter: 4 }); // moved on after 5 seconds
act(() => { vi.advanceTimersByTime(DWELL_MS - 1000); });
expect(result.current.readingHistory.chapters).toEqual({});
rerender({ active: false, book: 'JHN', chapter: 4 });
act(() => { vi.advanceTimersByTime(DWELL_MS * 2); });
expect(result.current.readingHistory.chapters).toEqual({});
});
test('signing in merges with the account\'s history and sends back what it lacked', async () => {
const { result } = setup({ active: false });
localStorage.setItem('reading-history', JSON.stringify({ chapters: { 'GEN.1': { n: 1, last: '2026-10-01' } }, days: ['2026-10-01'] }));
const fresh = renderHook(() => useReadingHistory({ active: false, book: 'GEN', chapter: 1 }));
await act(async () => { await fresh.result.current.pullReadingHistory(); });
expect(Object.keys(fresh.result.current.readingHistory.chapters).sort()).toEqual(['GEN.1', 'ROM.8']);
expect(saveRemoteHistory).toHaveBeenCalled(); // the account didn't have GEN.1
expect(result.current.readingHistory).toBeTruthy();
});
test('signing out clears this device\'s history', async () => {
const { result } = setup();
act(() => { vi.advanceTimersByTime(DWELL_MS + 100); });
expect(Object.keys(result.current.readingHistory.chapters)).toHaveLength(1);
act(() => result.current.stopReadingHistory());
expect(result.current.readingHistory).toEqual({ chapters: {}, days: [] });
expect(localStorage.getItem('reading-history')).toBeNull();
});
+17 -9
View File
@@ -1,18 +1,23 @@
// Read-only share link for the open project: status, enable, revoke, copy.
// Read-only share link for the open project: status, enable, change its rules (expiry, passcode), revoke, copy.
import { useEffect, useState } from 'react';
import { disableSharing, enableSharing, getShareStatus } from '../syncService.js';
export function useShareLink(projectId) {
const [shareToken, setShareToken] = useState(null);
const [expiresAt, setExpiresAt] = useState(null);
const [hasPasscode, setHasPasscode] = useState(false);
const [shareBusy, setShareBusy] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
const [shareError, setShareError] = useState('');
const apply = (data) => { setShareToken(data.shareToken ?? null); setExpiresAt(data.expiresAt ?? null); setHasPasscode(!!data.hasPasscode); };
useEffect(() => {
setShareToken(null);
if (!projectId) return;
setShareToken(null); setExpiresAt(null); setHasPasscode(false); setShareError('');
if (!projectId) return undefined;
let active = true;
getShareStatus(projectId).then((result) => {
if (active && result.ok) setShareToken(result.data.shareToken);
if (active && result.ok) apply(result.data);
});
return () => { active = false; };
}, [projectId]);
@@ -21,11 +26,14 @@ export function useShareLink(projectId) {
? `${window.location.origin}${window.location.pathname}?share=${shareToken}`
: '';
const enable = async () => {
// Turns the link on, or changes its rules: { expiresInDays?: number | null, passcode?: string | null }
const enable = async (options = {}) => {
setShareBusy(true);
const result = await enableSharing(projectId);
setShareError('');
const result = await enableSharing(projectId, options);
setShareBusy(false);
if (result.ok) setShareToken(result.data.shareToken);
if (result.ok) apply(result.data); else setShareError(result.error ?? 'Could not save the link settings.');
return result.ok;
};
const revoke = async () => {
@@ -33,7 +41,7 @@ export function useShareLink(projectId) {
setShareBusy(true);
const result = await disableSharing(projectId);
setShareBusy(false);
if (result.ok) setShareToken(null);
if (result.ok) apply({});
};
const copy = () => {
@@ -44,5 +52,5 @@ export function useShareLink(projectId) {
});
};
return { shareToken, shareUrl, shareBusy, shareCopied, enable, revoke, copy };
return { shareToken, shareUrl, expiresAt, hasPasscode, shareBusy, shareCopied, shareError, enable, revoke, copy };
}
+16 -8
View File
@@ -1,20 +1,28 @@
// A ?share=TOKEN URL shows a read-only view of someone else's project. No
// login is needed, so this runs independently of auth.
import { useEffect, useState } from 'react';
// login is needed, so this runs independently of auth. A link may need a passcode.
import { useCallback, useEffect, useState } from 'react';
import { getSharedProject } from '../syncService.js';
export function useSharedView() {
const [token] = useState(() => new URLSearchParams(window.location.search).get('share'));
const [project, setProject] = useState(null);
const [error, setError] = useState('');
const [needsPasscode, setNeedsPasscode] = useState(false);
const [passcodeError, setPasscodeError] = useState('');
const [checking, setChecking] = useState(false);
useEffect(() => {
if (!token) return;
getSharedProject(token).then((result) => {
if (result.ok) setProject(result.data);
else setError(result.error ?? 'This share link is invalid or has been revoked.');
const load = useCallback((passcode) => {
setChecking(true);
return getSharedProject(token, passcode).then((result) => {
setChecking(false);
if (result.ok) { setProject(result.data); setNeedsPasscode(false); setPasscodeError(''); return; }
if (result.data?.needsPasscode) { setNeedsPasscode(true); setPasscodeError(passcode ? result.error : ''); return; }
setNeedsPasscode(false);
setError(result.error ?? 'This share link is invalid or has been revoked.');
});
}, [token]);
return { token, project, error };
useEffect(() => { if (token) load(); }, [token, load]);
return { token, project, error, needsPasscode, passcodeError, checking, submitPasscode: load };
}
+8 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import AdminSignup from '../components/AdminSignup.jsx';
import PageShell from '../components/study/PageShell.jsx';
import { buildExportHtml } from '../utils/bibleUtils.jsx';
import {
@@ -13,7 +14,7 @@ import {
export default function AdminPage() {
const { authUser, goHome } = useApp();
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects'
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects' | 'signup'
const [adminUsers, setAdminUsers] = useState([]);
const [adminProjects, setAdminProjects] = useState([]);
const [adminLoading, setAdminLoading] = useState(false);
@@ -79,8 +80,14 @@ export default function AdminPage() {
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'projects' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
Projects ({adminProjects.length})
</button>
<button type="button" onClick={() => setAdminTab('signup')}
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'signup' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
Sign-ups
</button>
</div>
{adminTab === 'signup' && <AdminSignup />}
{adminLoading && <p className="text-sm text-[var(--r-sub)]">Loading…</p>}
{adminError && <p className="text-sm text-[var(--r-danger,#b42318)]">{adminError}</p>}
+23 -7
View File
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
import ResetPasswordForm from '../components/ResetPasswordForm.jsx';
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
import { getSignupMode, loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
// What the app does, in four groups. Everything listed here exists in the app.
const FEATURES = [
@@ -90,7 +90,16 @@ function ThemeSwitch() {
export default function AuthPage() {
const { signIn, readerTheme } = useApp();
const shell = { style: themeVars(readerTheme) };
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' | 'forgot'
// An invite link (?invite=CODE) opens straight on "Create an account" with the code filled in
const linkInvite = new URLSearchParams(window.location.search).get('invite') ?? '';
const [authMode, setAuthMode] = useState(linkInvite ? 'register' : 'login'); // 'login' | 'register' | 'forgot'
const [signupMode, setSignupMode] = useState('open'); // what the server allows: 'open' | 'invite' | 'closed'
const [invite, setInvite] = useState(linkInvite);
useEffect(() => {
let live = true;
getSignupMode().then((r) => { if (live && r.ok && r.data?.mode) setSignupMode(r.data.mode); });
return () => { live = false; };
}, []);
const [authForm, setAuthForm] = useState({ email: '', password: '' });
const [authError, setAuthError] = useState('');
const [authBusy, setAuthBusy] = useState(false);
@@ -102,8 +111,7 @@ export default function AuthPage() {
e.preventDefault();
setAuthError('');
setAuthBusy(true);
const action = authMode === 'login' ? loginUser : registerUser;
const result = await action(authForm.email.trim(), authForm.password);
const result = authMode === 'login' ? await loginUser(authForm.email.trim(), authForm.password) : await registerUser(authForm.email.trim(), authForm.password, invite.trim());
setAuthBusy(false);
if (!result.ok) {
setAuthError(result.error ?? 'Something went wrong.');
@@ -240,6 +248,14 @@ export default function AuthPage() {
/>
{authMode === 'register' && <span className="mt-1 block text-xs text-[var(--r-sub)]">At least 8 characters.</span>}
</label>
{authMode === 'register' && signupMode === 'invite' && (
<label className="block text-sm">
Invite code
<input type="text" required autoComplete="off" value={invite} onChange={(e) => setInvite(e.target.value)} placeholder="ABCD-EFGH-JKMN"
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 font-mono text-sm uppercase tracking-wider text-[var(--r-text)]" />
<span className="mt-1 block text-xs text-[var(--r-sub)]">Sign-up here is by invitation. Ask the administrator for a code.</span>
</label>
)}
{authError && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
<button
type="submit"
@@ -253,7 +269,7 @@ export default function AuthPage() {
<button type="button" onClick={() => { setAuthMode('forgot'); setAuthError(''); }}
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Forgot your password?</button>
)}
<button
{(signupMode !== 'closed' || authMode === 'register') && <button
type="button"
onClick={() => {
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
@@ -262,7 +278,7 @@ export default function AuthPage() {
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
>
{authMode === 'login' ? 'Need an account? Sign up' : 'Already have an account? Sign in'}
</button>
</button>}
</>
)}
</div>
+14 -2
View File
@@ -1,6 +1,10 @@
// The Export stage: take the finished study out as a file, or share it read-only.
import { useApp } from '../context/AppContext.js';
import { useShareLink } from '../hooks/useShareLink.js';
import HandoutExport from '../components/HandoutExport.jsx';
import SessionSchedule from '../components/SessionSchedule.jsx';
import CoTeachers from '../components/CoTeachers.jsx';
import LinkRules from '../components/LinkRules.jsx';
import StudioShell from '../components/study/StudioShell.jsx';
import { exportProjectDocx, exportProjectHtml, exportProjectMarkdown, printProjectPdf } from '../utils/projectExport.js';
@@ -17,7 +21,7 @@ function Row({ title, note, button, onClick }) {
}
export default function ExportPage() {
const { project, allChunks, setCurrentPage } = useApp();
const { project, allChunks, setCurrentPage, sharedStudies, deleteProject } = useApp();
const share = useShareLink(project?.id);
const filled = allChunks.filter((c) => (c.finalScript ?? '').trim()).length;
@@ -47,13 +51,21 @@ export default function ExportPage() {
className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
<button type="button" onClick={share.copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{share.shareCopied ? 'Copied!' : 'Copy'}</button>
</div>
<LinkRules expiresAt={share.expiresAt} hasPasscode={share.hasPasscode} busy={share.shareBusy} onSave={share.enable} />
{share.shareError && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{share.shareError}</p>}
<button type="button" onClick={share.revoke} disabled={share.shareBusy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{share.shareBusy ? 'Revoking…' : 'Revoke link'}</button>
</div>
) : (
<button type="button" onClick={share.enable} disabled={share.shareBusy} className={btn}>{share.shareBusy ? 'Enabling…' : 'Enable sharing'}</button>
<button type="button" onClick={() => share.enable()} disabled={share.shareBusy} className={btn}>{share.shareBusy ? 'Enabling…' : 'Enable sharing'}</button>
)}
</section>
<HandoutExport project={project} />
<SessionSchedule project={project} />
<CoTeachers projectId={project?.id} sharedBy={project && sharedStudies?.[project.id]} onLeave={() => { deleteProject(project.id); setCurrentPage('home'); }} />
<section className={card} aria-label="Pronunciation">
<Row title="Pronunciation guide" note="Every Greek and Hebrew word in this study, with transliterations." button="Open guide" onClick={() => setCurrentPage('pronunciation')} />
</section>
+44 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { filterStudies, isTagQuery, tagCounts } from '../utils/studyFilter.js';
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
import BookCover from '../components/BookCover.jsx';
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
@@ -20,6 +20,7 @@ export default function HomePage() {
const {
autoRestoredCount,
staleLocalProjects,
sharedStudies,
projectIndex,
planProgress,
readingActivity,
@@ -39,6 +40,7 @@ export default function HomePage() {
pullLatestFromServer,
resumeProject,
renameProjectInStorage,
setProjectSeries,
deleteProject,
handlePlayBookAudio,
handleStopBookAudio,
@@ -53,6 +55,9 @@ export default function HomePage() {
}); // 'recent' | 'title' | 'passage'
const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them
const [showAllTags, setShowAllTags] = useState(false);
const [seriesFilter, setSeriesFilter] = useState(''); // '' = all, '__none' = not in a series, or a series name
const [seriesEditingId, setSeriesEditingId] = useState(null);
const [seriesValue, setSeriesValue] = useState('');
const toggleTag = (tag) => setHomeTags((cur) => (cur.includes(tag) ? cur.filter((t) => t !== tag) : [...cur, tag]));
const [homeView, setHomeView] = useState(() => {
try { return localStorage.getItem('homeView') === 'list' ? 'list' : 'grid'; } catch { return 'grid'; }
@@ -103,12 +108,15 @@ export default function HomePage() {
const tagList = tagCounts(projectIndex);
const shownTags = showAllTags ? tagList : tagList.slice(0, 12);
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags })
const seriesNames = seriesList(projectIndex);
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter })
.sort((a, b) => {
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
});
// With series in use (and no single series chosen) the studies are grouped under their series name
const sections = seriesFilter === '' && seriesNames.length > 0 ? groupBySeries(visible) : [{ name: '', items: visible, flat: true }];
const fieldCls = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const linkBtn = 'rounded-lg px-3 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5';
const notice = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
@@ -166,6 +174,15 @@ export default function HomePage() {
<option value="passage">Passage</option>
</select>
</label>
{seriesNames.length > 0 && (
<label className="text-sm text-[var(--r-sub)]">Series{' '}
<select value={seriesFilter} onChange={(e) => setSeriesFilter(e.target.value)} className={`${fieldCls} ml-1`}>
<option value="">All studies</option>
{seriesNames.map((sr) => <option key={sr.name} value={sr.name}>{sr.name} ({sr.count})</option>)}
<option value="__none">Not in a series</option>
</select>
</label>
)}
<div role="group" aria-label="Studies layout" className="ml-auto flex overflow-hidden rounded-xl border border-[var(--r-line)] text-sm">
{[['grid', 'Grid'], ['list', 'List']].map(([id, label]) => (
<button key={id} type="button" aria-pressed={homeView === id} onClick={() => setHomeView(id)}
@@ -174,6 +191,7 @@ export default function HomePage() {
</div>
</div>
<datalist id="series-names">{seriesNames.map((sr) => <option key={sr.name} value={sr.name} />)}</datalist>
{tagList.length > 0 && (
<div className="-mt-1 mb-4 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by tag">
<span className="mr-1 text-xs text-[var(--r-sub)]">Tags</span>
@@ -226,8 +244,15 @@ export default function HomePage() {
</div>
)}
{sections.map((section) => (
<div key={section.name || '__none'} className="mb-5 last:mb-0">
{!section.flat && (
<h3 className="mb-2 flex items-baseline gap-2 text-sm font-medium">
{section.name || 'Not in a series'} <span className="text-xs font-normal text-[var(--r-sub)]">{section.items.length}</span>
</h3>
)}
<div className={homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}>
{visible.map((entry) => (
{section.items.map((entry) => (
<div key={entry.id} className={homeView === 'list'
? 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3 sm:flex-row sm:items-center sm:gap-4'
: 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5'}>
@@ -249,7 +274,20 @@ export default function HomePage() {
) : (
<div className={`flex items-start gap-2 ${homeView === 'list' ? 'flex-wrap items-baseline' : 'justify-between'}`}>
<h2 className="text-base font-medium">{entry.title}</h2>
{sharedStudies?.[entry.id] !== undefined && (
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]" title="Another account invited you to edit this study">Shared{sharedStudies[entry.id] ? ` by ${sharedStudies[entry.id]}` : ''}</span>
)}
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
<button type="button" onClick={() => { setSeriesEditingId(entry.id); setSeriesValue(entry.series ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Put this study in a series">{entry.series ? '⧉ Series' : '⧉ Add to series'}</button>
</div>
)}
{seriesEditingId === entry.id && (
<div className="mt-2 flex items-center gap-2">
<input type="text" autoFocus list="series-names" aria-label="Series name" placeholder="Series name (blank to remove)" value={seriesValue} onChange={(e) => setSeriesValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); } else if (e.key === 'Escape') setSeriesEditingId(null); }}
className={`${fieldCls} flex-1`} />
<button type="button" onClick={() => { setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setSeriesEditingId(null)} aria-label="Cancel" className="text-sm text-[var(--r-sub)]">×</button>
</div>
)}
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
@@ -267,11 +305,13 @@ export default function HomePage() {
</div>
<div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">Delete</button>
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">{sharedStudies?.[entry.id] !== undefined ? 'Leave' : 'Delete'}</button>
</div>
</div>
))}
</div>
</div>
))}
</>
)}
</section>
+4
View File
@@ -10,6 +10,7 @@ import { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildChapterSummary } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
import PresentMode from '../components/PresentMode.jsx';
import IqOutlineAttach from '../components/iq/IqOutlineAttach.jsx';
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
@@ -47,6 +48,7 @@ export default function OutlinePage() {
const [pendingFocus, setPendingFocus] = useState(null);
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
const [shareOpen, setShareOpen] = useState(false);
const [presenting, setPresenting] = useState(false);
const [lookup, setLookup] = useState({ id: null, busy: false, error: '' });
// The one way every change is made, always from the latest outline (typing is fast).
@@ -142,6 +144,7 @@ export default function OutlinePage() {
return (
<StudioShell stage="outline">
{presenting && <PresentMode mode="outline" title={title} subtitle={subtitle} tree={tree} onClose={() => setPresenting(false)} />}
<main className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-4">
<h1 className="text-xl font-medium">Outline</h1>
@@ -171,6 +174,7 @@ export default function OutlinePage() {
Build from my notes{fromNotes > 0 ? ` (${fromNotes})` : ''}
</button>
<span className="mx-1 hidden h-6 w-px bg-[var(--r-line)] sm:block" aria-hidden="true" />
<button type="button" onClick={() => setPresenting(true)} disabled={tree.length === 0} className={btn} title="Step through the outline full-screen, with a timer and your planned minutes">Present</button>
<button type="button" onClick={() => setShareOpen(true)} disabled={tree.length === 0} className={btn}>Share</button>
<button type="button" onClick={print} disabled={tree.length === 0} className={btn}>Print / PDF</button>
<details className="relative">
+3
View File
@@ -1,6 +1,7 @@
// Reading plans home, styled to match the reader (paper / sepia / dark). One big
// "Continue reading" card, your plans in progress, and a browsable catalog. Details,
// the custom-plan builder and everything else open as sheets.
import ReadingProgress from '../components/ReadingProgress.jsx';
import IqPlanExport from '../components/iq/IqPlanExport.jsx';
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
@@ -39,6 +40,7 @@ export default function ReadingPlansPage() {
openPlanReading, studyReadingPlanDay, studyBuildBusy,
readingPlanSyncStatus, readingPlanSyncError, retryReadingPlanSync,
readerTranslation, planTranslationPref,
readingHistory, readReadingPlanDay,
} = useApp();
const [detail, setDetail] = useState(null); // { planId, progressId? }
@@ -219,6 +221,7 @@ export default function ReadingPlansPage() {
</div>
</>
)}
<div className="mt-8"><ReadingProgress history={readingHistory ?? { chapters: {}, days: [] }} planProgress={planProgress} activity={readingActivity} onOpenBook={(abbrev) => readReadingPlanDay(abbrev, 1)} /></div>
<div className="mt-8"><IqPlanExport mcheyne /></div>
</main>
+6
View File
@@ -2,6 +2,8 @@
// write the final script beside your study notes. Claude prompts and the
// pronunciation guide live here because this is where they're needed.
import { useState } from 'react';
import PresentMode from '../components/PresentMode.jsx';
import DictateButton, { appendSpoken } from '../components/DictateButton.jsx';
import { useIqAvailable } from '../hooks/useIq.js';
import { chunkRefs, gatherIqContext } from '../utils/iqbible.js';
import { useApp } from '../context/AppContext.js';
@@ -20,6 +22,7 @@ export default function ScriptPage() {
const [notice, setNotice] = useState('');
const iq = useIqAvailable();
const [gathering, setGathering] = useState(false);
const [presenting, setPresenting] = useState(false);
const { toast } = useToast();
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
@@ -138,6 +141,8 @@ export default function ScriptPage() {
</button>
)}
<button type="button" onClick={() => setCurrentPage('pronunciation')} className={btn}>Pronunciation guide</button>
<button type="button" onClick={() => setPresenting(true)} className={btn} title="Full-screen, large type, with a timer and auto-scroll">Present</button>
<DictateButton label="Dictate script" onText={(said) => set({ finalScript: appendSpoken(script, said) })} />
{notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>}
</div>
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
@@ -165,6 +170,7 @@ export default function ScriptPage() {
)}
</aside>
</div>
{presenting && <PresentMode mode="script" title={reference} subtitle={project.title} script={script} onClose={() => setPresenting(false)} />}
</StudioShell>
);
}
+16
View File
@@ -85,6 +85,8 @@ export default function SettingsPage() {
ntfyUrl: authUser?.ntfyUrl ?? '',
ntfyTopic: authUser?.ntfyTopic ?? '',
reminderTime: authUser?.reminderTime ?? '07:00',
votdEnabled: authUser?.votdEnabled ?? false,
votdTime: authUser?.votdTime ?? '06:30',
});
const [reminderBusy, setReminderBusy] = useState(false);
const [reminderError, setReminderError] = useState('');
@@ -426,6 +428,20 @@ export default function SettingsPage() {
/>
</label>
</div>
{authUser?.votdAvailable && (
<div className="rounded-xl border border-[var(--r-line)] p-3">
<label className="flex items-center gap-2 text-sm font-medium text-[var(--r-text)]">
<input type="checkbox" checked={reminderForm.votdEnabled} onChange={(e) => setReminderForm((f) => ({ ...f, votdEnabled: e.target.checked }))} className="h-4 w-4 accent-[var(--r-accent)]" />
Also send the verse of the day
</label>
<label className="mt-2 block max-w-xs text-sm font-medium text-[var(--r-text)]">
Send it at
<input type="time" value={reminderForm.votdTime} onChange={(e) => setReminderForm((f) => ({ ...f, votdTime: e.target.value }))}
className="mt-1 block w-full 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)]" />
</label>
<span className="mt-1 block text-xs text-[var(--r-sub)]">A short notification with today’s verse (KJV) to the same topic, even if the reading reminder is off.</span>
</div>
)}
<label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
ntfy server <span className="text-[var(--r-sub)]">(optional — defaults to ntfy.sh)</span>
<input
+9 -5
View File
@@ -14,7 +14,7 @@ const primaryBtn = 'rounded-xl bg-[var(--r-accent)] px-5 py-2.5 text-sm font-med
// Band tints that alternate from chunk to chunk so neighbouring chunks are easy to tell apart.
const BANDS = ['color-mix(in srgb, var(--r-accent) 13%, transparent)', 'color-mix(in srgb, var(--r-sub) 17%, transparent)'];
function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, withTitle }) {
function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, withTitle, userTemplates = [], onDeleteTemplate }) {
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
@@ -35,8 +35,12 @@ function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, wi
<label className="block text-sm sm:col-span-2">Start from
<select value={setup.template ?? 'blank'} onChange={(e) => onField('template', e.target.value)} className={inputCls}>
{STUDY_TEMPLATES.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
{userTemplates.length > 0 && <optgroup label="Your templates">{userTemplates.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</optgroup>}
</select>
<span className="mt-1 block text-xs text-[var(--r-sub)]">{getTemplate(setup.template).description}</span>
<span className="mt-1 block text-xs text-[var(--r-sub)]">{getTemplate(setup.template, userTemplates).description}</span>
{setup.template?.startsWith('user-') && onDeleteTemplate && (
<button type="button" onClick={() => { onDeleteTemplate(setup.template); onField('template', 'blank'); }} className="mt-1 text-xs text-[var(--r-danger,#b42318)] hover:underline">Delete this template</button>
)}
</label>
)}
</div>
@@ -49,8 +53,8 @@ function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, wi
}
export default function SetupPage() {
const { project, setProject, allChunks, updateProject, setCurrentPage } = useApp();
const b = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage });
const { project, setProject, allChunks, updateProject, setCurrentPage, userTemplates, deleteUserTemplate } = useApp();
const b = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage, userTemplates });
const { activeChapter, activeChapterIndex } = b;
const chapters = project?.chapters ?? [];
@@ -73,7 +77,7 @@ export default function SetupPage() {
<h1 className="mb-1 text-xl font-medium">New study</h1>
<p className="mb-5 text-sm text-[var(--r-sub)]">Pick a chapter, then cut it into chunks. You’ll study one chunk at a time.</p>
<PassageForm setup={b.setup} onField={b.handleSetupField} onTitleChange={onTitleChange} onLoad={b.handleLoadChapter}
loading={b.loadingChapter} error={b.errorMessage} withTitle />
loading={b.loadingChapter} error={b.errorMessage} withTitle userTemplates={userTemplates} onDeleteTemplate={deleteUserTemplate} />
</section>
)}
+2 -2
View File
@@ -16,7 +16,7 @@ import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.j
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary } = useApp();
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate } = useApp();
// Short-lived status line (word suggestions, lookups, …)
const [statusMessage, setStatusMessage] = useState('');
@@ -132,7 +132,7 @@ export default function StudyPage() {
<section aria-label="Notes" className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[11]">
<h2 className="shrink-0 border-b border-[var(--r-line)] px-4 py-2.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</h2>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateId} />
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} />
</div>
</section>
<section aria-label="Resources" className="flex min-h-[24rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[10]">
+34 -10
View File
@@ -94,8 +94,8 @@ export async function getShareStatus(id) {
}
/** Enables sharing (or returns the existing token if already enabled). Returns { shareToken }. */
export async function enableSharing(id) {
return request('POST', `/projects/${id}/share`);
export async function enableSharing(id, options = {}) {
return request('POST', `/projects/${id}/share`, options);
}
/** Revokes a project's share link. */
@@ -103,24 +103,32 @@ export async function disableSharing(id) {
return request('DELETE', `/projects/${id}/share`);
}
// ---- co-teachers: other accounts that may edit a study ----
export async function getCollaborators(id) { return request('GET', `/projects/${id}/collaborators`); }
/** A single-use link (valid 7 days) that lets whoever opens it, signed in, edit this study. -> { token, expiresAt } */
export async function createEditInvite(id) { return request('POST', `/projects/${id}/collaborators/invite`); }
export async function removeCollaborator(id, userId) { return request('DELETE', `/projects/${id}/collaborators/${userId}`); }
/** Uses an invite link: -> { projectId, title } */
export async function joinStudy(token) { return request('POST', `/join/${encodeURIComponent(token)}`); }
/** The project's read-only outline link: { shareToken } (null when off). A separate link from the study share. */
export async function getOutlineShareStatus(id) {
return request('GET', `/projects/${id}/outline-share`);
}
export async function enableOutlineSharing(id) {
return request('POST', `/projects/${id}/outline-share`);
export async function enableOutlineSharing(id, options = {}) {
return request('POST', `/projects/${id}/outline-share`, options);
}
export async function disableOutlineSharing(id) {
return request('DELETE', `/projects/${id}/outline-share`);
}
/** Public lookup — no session. Returns { title, translation, outlines: [{ scope, label, tree }] }. */
export async function getSharedOutline(token) {
return request('GET', `/share-outline/${token}`);
export async function getSharedOutline(token, passcode) {
return request('GET', `/share-outline/${token}`, undefined, { headers: passcode ? { 'X-Share-Passcode': passcode } : {} });
}
/** Public lookup — no session required. Returns the full project for a valid share token. */
export async function getSharedProject(token) {
return request('GET', `/share/${token}`);
export async function getSharedProject(token, passcode) {
return request('GET', `/share/${token}`, undefined, { headers: passcode ? { 'X-Share-Passcode': passcode } : {} });
}
/**
@@ -152,8 +160,13 @@ export async function getCurrentUser() {
return result;
}
export async function registerUser(email, password) {
return request('POST', '/auth/register', { email, password });
export async function registerUser(email, password, invite) {
return request('POST', '/auth/register', { email, password, invite });
}
/** Who may sign up here: { mode: 'open' | 'invite' | 'closed' }. */
export async function getSignupMode() {
return request('GET', '/auth/signup-mode');
}
export async function loginUser(email, password) {
@@ -209,6 +222,11 @@ export async function saveRemoteMarks(marks) {
return request('PUT', '/reader/marks', marks);
}
/** What the person has read: { chapters, days }. */
export async function getRemoteHistory() { return request('GET', '/reader/history'); }
/** Merges this device's reading history into the account's; returns the combined history. */
export async function saveRemoteHistory(history) { return request('PUT', '/reader/history', history); }
/** Sends a test notification to the given ntfy topic/server (or the saved ones). */
export async function sendReminderTest(settings) {
return request('POST', '/auth/reminder-test', settings);
@@ -255,6 +273,12 @@ export async function adminResetPassword(id) {
return request('POST', `/admin/users/${id}/reset-password`);
}
/** The administrator's sign-up setting and invite codes. */
export async function adminGetSignup() { return request('GET', '/admin/signup'); }
export async function adminSetSignupMode(mode) { return request('PUT', '/admin/signup', { mode }); }
export async function adminCreateInvite({ note, days } = {}) { return request('POST', '/admin/invites', { note, days }); }
export async function adminDeleteInvite(code) { return request('DELETE', `/admin/invites/${encodeURIComponent(code)}`); }
export async function adminListProjects() {
return request('GET', '/admin/projects');
}
+16
View File
@@ -66,6 +66,22 @@ describe('Home page', () => {
expect(titles()).toHaveLength(3);
});
test('a study can be put in a series; the dashboard groups by series and can show just one', async () => {
seedProject({ id: 'p1', title: 'Titus Notes' });
seedProject({ id: 'p2', title: 'New Birth' });
const user = userEvent.setup();
await renderApp();
const card = (title) => screen.getByRole('heading', { name: title }).closest('div.rounded-2xl');
await user.click(within(card('Titus Notes')).getByRole('button', { name: /add to series/i }));
await user.type(screen.getByLabelText('Series name'), 'Pastoral letters{Enter}');
expect(await screen.findByRole('heading', { level: 3, name: /Pastoral letters/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 3, name: /Not in a series/ })).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('bible-study-project-p1')).series).toBe('Pastoral letters');
await user.selectOptions(screen.getByLabelText(/^Series/), 'Pastoral letters');
expect(screen.queryByRole('heading', { name: 'New Birth' })).toBeNull();
expect(screen.getByRole('heading', { name: 'Titus Notes' })).toBeInTheDocument();
});
test('studies can be shown as a grid or a list, and the choice is remembered', async () => {
seedProject();
seedProject({ id: 'p2', title: 'Second Study' });
+85
View File
@@ -0,0 +1,85 @@
// A one-page-per-session handout for a group: the passage, the outline, discussion questions and room to write.
// It opens as a print-friendly page ("Save as PDF" in the print dialog).
import { formatChunkReference, getChunkVerseEntries } from './bibleUtils.jsx';
import { flatten } from './outline.js';
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const lines = (text) => String(text ?? '').split('\n').map((l) => l.replace(/^\s*(?:[-*•–]|\d{1,2}[.)])\s+/, '').trim()).filter(Boolean);
// Every session (chunk) of the study, in order, with where it sits.
export function sessionsFor(project) {
const out = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
for (const chunk of chapter.chunks ?? []) out.push({ chunk, chapterIndex, number: out.length + 1, reference: formatChunkReference(project, chapterIndex, chunk, '–') });
});
return out;
}
// The outline points for a session: its own outline, else the study outline's points linked to it.
export function outlineForSession(project, chunk) {
if ((chunk.outline ?? []).length) return chunk.outline;
const linked = (project.outline ?? []).filter((n) => n.chunkId === chunk.id);
return linked.length ? linked : [];
}
// The questions to print: the ones written for the session, else its Application notes as "Think about" prompts.
export function questionsFor(chunk) {
const own = lines(chunk.discussionQuestions);
if (own.length) return { kind: 'questions', items: own };
const fallback = lines(chunk.application);
return fallback.length ? { kind: 'think', items: fallback } : { kind: 'none', items: [] };
}
function sessionHtml(project, session, { answerLines }) {
const { chunk, chapterIndex, number, reference } = session;
const series = (project.series ?? '').trim();
const title = (chunk.episodeTitle ?? '').trim();
const num = (chunk.episodeNumber ?? '').toString().trim() || number;
const verses = getChunkVerseEntries(project, chapterIndex, chunk);
const outline = flatten(outlineForSession(project, chunk));
const q = questionsFor(chunk);
const blank = (n) => Array.from({ length: n }, () => '<div class="rule"></div>').join('');
return `<section class="page">
<header>
<p class="kicker">${esc(series || project.title)}</p>
<h1>Session ${esc(num)}${title ? `: ${esc(title)}` : ''}</h1>
<p class="ref">${esc(reference)} <span>(${esc(project.translation ?? 'BSB')})</span></p>
<p class="meta">Name: ____________________________ &nbsp;&nbsp; Date: ________________</p>
</header>
${verses.length ? `<div class="passage">${verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${esc(v.text)}</p>`).join('')}</div>` : ''}
${outline.length ? `<h2>Outline</h2><ul class="outline">${outline.map(({ node, depth, label }) => `<li style="margin-left:${depth * 1.2}em"><b>${esc(label)}</b> ${esc(node.text || '')}${node.ref ? ` <i>(${esc(node.ref)})</i>` : ''}</li>`).join('')}</ul>` : ''}
${q.items.length ? `<h2>${q.kind === 'questions' ? 'Discussion questions' : 'Think about it'}</h2><ol class="questions">${q.items.map((item) => `<li>${esc(item)}${answerLines ? blank(2) : ''}</li>`).join('')}</ol>` : ''}
${answerLines ? `<h2>Notes</h2>${blank(q.items.length ? 4 : 10)}` : ''}
<footer>${esc(project.title)}</footer>
</section>`;
}
// ids: the chunk ids to include, or null for every session
export function buildHandoutHtml(project, ids = null, { answerLines = true } = {}) {
const sessions = sessionsFor(project).filter((s) => !ids || ids.includes(s.chunk.id));
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(project.title)} — handout</title><style>
@page { margin: 18mm 16mm; }
body { font-family: Georgia, 'Times New Roman', serif; color: #1f1b16; line-height: 1.5; margin: 0; font-size: 12.5pt; }
.page { page-break-after: always; min-height: 95vh; position: relative; }
.page:last-child { page-break-after: auto; }
.kicker { margin: 0; font: 600 9.5pt -apple-system, Helvetica, Arial, sans-serif; letter-spacing: .14em; text-transform: uppercase; color: #8a4a2a; }
h1 { margin: .15em 0 0; font-size: 22pt; font-weight: 600; }
.ref { margin: .2em 0 .4em; font-size: 13pt; color: #8a4a2a; font-weight: 600; } .ref span { color: #6b6458; font-weight: 400; font-size: 10pt; }
.meta { color: #6b6458; font: 10pt -apple-system, Helvetica, Arial, sans-serif; margin: .2em 0 1em; }
h2 { font: 600 10pt -apple-system, Helvetica, Arial, sans-serif; letter-spacing: .12em; text-transform: uppercase; color: #6b6458; border-bottom: 1px solid #d8cfbb; padding-bottom: 3px; margin: 1.3em 0 .5em; }
.passage { background: #f6f1e6; border-left: 3px solid #a84e27; padding: .4em 1em; } .passage p { margin: .35em 0; } sup { color: #a84e27; font-size: 70%; font-family: -apple-system, Helvetica, Arial, sans-serif; }
.outline { list-style: none; padding: 0; margin: 0; } .outline li { margin-bottom: .25em; } .outline b { color: #a84e27; }
.questions { padding-left: 1.4em; } .questions li { margin-bottom: .7em; }
.rule { border-bottom: 1px solid #c9bfa8; height: 1.7em; }
footer { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; color: #9b9484; font: 9pt -apple-system, Helvetica, Arial, sans-serif; }
</style></head><body>${sessions.map((s) => sessionHtml(project, s, { answerLines })).join('')}</body></html>`;
}
export function printHandout(project, ids = null, options) {
const win = window.open('', '_blank');
if (!win) return false;
win.document.write(buildHandoutHtml(project, ids, options));
win.document.close();
win.onload = () => { win.focus(); win.print(); };
return true;
}
+48
View File
@@ -0,0 +1,48 @@
import { test, expect } from 'vitest';
import { buildHandoutHtml, outlineForSession, questionsFor, sessionsFor } from './handout.js';
const chunk = (id, s, e, x = {}) => ({ id, startVerse: s, endVerse: e, observation: '', interpretation: '', application: '', ...x });
const project = {
title: 'Romans 8 <study>', series: 'Romans', translation: 'BSB',
outline: [{ id: 'o1', text: 'No condemnation', ref: '', chunkId: 'a', children: [{ id: 'o2', text: 'The Spirit gives life', ref: 'Rom 8:2', chunkId: null, children: [] }] }],
chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'There is now no condemnation.' }, { number: 2, text: 'For the law of the Spirit of life.' }, { number: 12, text: 'So then, brothers.' }], chunks: [
chunk('a', 1, 2, { episodeNumber: '1', episodeTitle: 'Free at last', discussionQuestions: '1. What is condemnation?\n- Why does it end?', application: 'Rest in grace' }),
chunk('b', 12, 12, { application: '- Walk by the Spirit\n- Put sin to death' }),
] }],
};
test('sessions are numbered in order with their passages', () => {
expect(sessionsFor(project).map((s) => [s.number, s.reference])).toEqual([[1, 'Romans 8:1–2'], [2, 'Romans 8:12']]);
});
test('questions written for a session are used; otherwise the Application notes become prompts', () => {
expect(questionsFor(project.chapters[0].chunks[0])).toEqual({ kind: 'questions', items: ['What is condemnation?', 'Why does it end?'] });
expect(questionsFor(project.chapters[0].chunks[1])).toEqual({ kind: 'think', items: ['Walk by the Spirit', 'Put sin to death'] });
expect(questionsFor(chunk('z', 1, 1))).toEqual({ kind: 'none', items: [] });
});
test('a session uses the study outline points linked to it', () => {
expect(outlineForSession(project, project.chapters[0].chunks[0])).toHaveLength(1);
expect(outlineForSession(project, project.chapters[0].chunks[1])).toEqual([]);
expect(outlineForSession(project, { id: 'a', outline: [{ id: 'x', text: 'Own', children: [] }] })[0].id).toBe('x');
});
test('the handout has a page per session with passage, outline and questions, and escapes text', () => {
const html = buildHandoutHtml(project);
expect(html.match(/class="page"/g)).toHaveLength(2);
expect(html).toContain('Session 1: Free at last');
expect(html).toContain('<sup>1</sup> There is now no condemnation.');
expect(html).toContain('The Spirit gives life');
expect(html).toContain('Discussion questions');
expect(html).toContain('Think about it');
expect(html).toContain('Romans 8 &lt;study&gt;'); // no raw markup from the title
expect(html).not.toContain('<study>');
expect(html).toContain('class="rule"');
});
test('one session only, and no writing lines when switched off', () => {
const html = buildHandoutHtml(project, ['b'], { answerLines: false });
expect(html.match(/class="page"/g)).toHaveLength(1);
expect(html).toContain('Session 2');
expect(html).not.toContain('class="rule"');
});
+52
View File
@@ -0,0 +1,52 @@
// A calendar file (.ics) any calendar app can import: one event per entry. Dates are plain calendar days
// (all-day events) unless a time is given, in which case the event is at that clock time wherever the file is opened.
const pad = (n) => String(n).padStart(2, '0');
const day = (d) => `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
const stamp = (d) => `${day(d)}T${pad(d.getHours())}${pad(d.getMinutes())}00`;
const utcStamp = (d) => `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
// Escapes text for an ICS field, and folds long lines the way the format requires (75 characters).
export const icsText = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
export function fold(line) {
const out = [];
let rest = line;
while (rest.length > 75) { out.push(rest.slice(0, 75)); rest = ` ${rest.slice(75)}`; }
out.push(rest);
return out.join('\r\n');
}
// events: [{ date: Date, title, description?, time?: 'HH:MM', minutes?: number }] -> the file's text
export function buildIcs(events, { name = 'Bible study', now = new Date() } = {}) {
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Bible Study Project//EN', 'CALSCALE:GREGORIAN', `X-WR-CALNAME:${icsText(name)}`];
events.forEach((e, i) => {
lines.push('BEGIN:VEVENT', `UID:${utcStamp(now)}-${i}@bible-study-project`, `DTSTAMP:${utcStamp(now)}`);
const m = /^(\d{1,2}):(\d{2})$/.exec(e.time ?? '');
if (m) {
const start = new Date(e.date.getFullYear(), e.date.getMonth(), e.date.getDate(), Number(m[1]), Number(m[2]));
const end = new Date(start.getTime() + (e.minutes ?? 60) * 60_000);
lines.push(`DTSTART:${stamp(start)}`, `DTEND:${stamp(end)}`);
} else {
const next = new Date(e.date.getFullYear(), e.date.getMonth(), e.date.getDate() + 1);
lines.push(`DTSTART;VALUE=DATE:${day(e.date)}`, `DTEND;VALUE=DATE:${day(next)}`);
}
lines.push(`SUMMARY:${icsText(e.title)}`);
if (e.description) lines.push(`DESCRIPTION:${icsText(e.description)}`);
lines.push('END:VEVENT');
});
lines.push('END:VCALENDAR');
return `${lines.map(fold).join('\r\n')}\r\n`;
}
// The date of each of `count` sessions: the first on `start`, then every `everyDays` days.
export function sessionDates(start, count, everyDays = 7) {
return Array.from({ length: count }, (_, i) => new Date(start.getFullYear(), start.getMonth(), start.getDate() + i * everyDays));
}
export function downloadIcs(text, fileName) {
const url = URL.createObjectURL(new Blob([text], { type: 'text/calendar;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = fileName;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+33
View File
@@ -0,0 +1,33 @@
import { test, expect } from 'vitest';
import { buildIcs, fold, icsText, sessionDates } from './icsFile.js';
const NOW = new Date(Date.UTC(2026, 9, 6, 12, 0, 0));
test('sessions fall on the start date and then every N days', () => {
const d = sessionDates(new Date(2026, 9, 11), 3, 7);
expect(d.map((x) => `${x.getMonth() + 1}/${x.getDate()}`)).toEqual(['10/11', '10/18', '10/25']);
expect(sessionDates(new Date(2026, 11, 28), 2, 7).map((x) => `${x.getFullYear()}-${x.getMonth() + 1}-${x.getDate()}`)).toEqual(['2026-12-28', '2027-1-4']); // across the new year
});
test('all-day events end the next day', () => {
const ics = buildIcs([{ date: new Date(2026, 9, 11), title: 'Romans — Ep 1: Grace' }], { name: 'Romans', now: NOW });
expect(ics.startsWith('BEGIN:VCALENDAR\r\n')).toBe(true);
expect(ics).toContain('DTSTART;VALUE=DATE:20261011');
expect(ics).toContain('DTEND;VALUE=DATE:20261012');
expect(ics).toContain('SUMMARY:Romans — Ep 1: Grace');
expect(ics).toContain('X-WR-CALNAME:Romans');
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
});
test('a time makes a timed event with a length', () => {
const ics = buildIcs([{ date: new Date(2026, 9, 11), title: 'Lesson', time: '09:30', minutes: 45 }], { now: NOW });
expect(ics).toContain('DTSTART:20261011T093000');
expect(ics).toContain('DTEND:20261011T101500');
});
test('special characters are escaped and long lines are folded', () => {
expect(icsText('A, B; C\nD\\E')).toBe('A\\, B\\; C\\nD\\\\E');
const folded = fold(`DESCRIPTION:${'x'.repeat(200)}`);
expect(folded.split('\r\n').every((l) => l.length <= 76)).toBe(true);
expect(folded.replace(/\r\n /g, '')).toBe(`DESCRIPTION:${'x'.repeat(200)}`);
});
+66
View File
@@ -0,0 +1,66 @@
// Your highlights and bookmarks as a file: grouped by colour (under the names you gave the colours), each in
// Bible order with the words you marked. Markdown for notes apps, Word for printing.
import { COLOR_NAMES, HIGHLIGHT_COLORS, colorTitle } from './highlightColors.js';
// highlights: [{ bookName, bookIndex, chapter, verse, startOffset, color, text, translation }]; bookmarks: [{ bookName, bookIndex, chapter, verse, color }]
export function buildMarksModel({ highlights = [], bookmarks = [], colorLabels = {} }) {
const order = (a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse || (a.startOffset ?? 0) - (b.startOffset ?? 0);
const colors = [...new Set([...HIGHLIGHT_COLORS, ...highlights.map((h) => h.color)])];
const groups = colors
.map((color) => ({
color,
title: colorTitle(color, colorLabels),
items: highlights.filter((h) => h.color === color).sort(order).map((h) => ({ ref: `${h.bookName} ${h.chapter}:${h.verse}`, text: (h.text ?? '').trim(), translation: h.translation ?? '' })),
}))
.filter((g) => g.items.length > 0);
const marks = bookmarks.slice().sort(order).map((b) => ({ ref: `${b.bookName} ${b.chapter}:${b.verse}`, color: COLOR_NAMES[b.color] ?? '' }));
return { groups, bookmarks: marks };
}
export function marksToMarkdown(model, { title = 'My highlights and bookmarks', date = new Date() } = {}) {
const out = [`# ${title}`, '', `*${date.toLocaleDateString()}*`, ''];
for (const g of model.groups) {
out.push(`## ${g.title}`, '');
for (const item of g.items) out.push(`- **${item.ref}**${item.translation ? ` (${item.translation})` : ''}${item.text ? ` — “${item.text.replace(/\s+/g, ' ')}”` : ''}`);
out.push('');
}
if (model.bookmarks.length) {
out.push('## Bookmarks', '');
for (const b of model.bookmarks) out.push(`- ${b.ref}${b.color ? ` (${b.color.toLowerCase()})` : ''}`);
out.push('');
}
if (!model.groups.length && !model.bookmarks.length) out.push('Nothing marked yet.', '');
return out.join('\n');
}
export async function buildMarksDocxBlob(model, { title = 'My highlights and bookmarks', date = new Date() } = {}) {
const { Document, HeadingLevel, Packer, Paragraph, TextRun } = await import('docx'); // the Word library only loads when used
const heading = (text) => new Paragraph({ text, heading: HeadingLevel.HEADING_2, spacing: { before: 280, after: 100 } });
const children = [
new Paragraph({ text: title, heading: HeadingLevel.TITLE }),
new Paragraph({ children: [new TextRun({ text: date.toLocaleDateString(), color: '7A7060', size: 21 })], spacing: { after: 160 } }),
];
for (const g of model.groups) {
children.push(heading(g.title));
for (const item of g.items) {
children.push(new Paragraph({
bullet: { level: 0 },
children: [new TextRun({ text: item.ref, bold: true }), ...(item.translation ? [new TextRun({ text: ` (${item.translation})`, color: '7A7060' })] : []), ...(item.text ? [new TextRun({ text: ` — “${item.text.replace(/\s+/g, ' ')}”` })] : [])],
spacing: { after: 60 },
}));
}
}
if (model.bookmarks.length) {
children.push(heading('Bookmarks'));
for (const b of model.bookmarks) children.push(new Paragraph({ bullet: { level: 0 }, children: [new TextRun({ text: b.ref }), ...(b.color ? [new TextRun({ text: ` (${b.color.toLowerCase()})`, color: '7A7060' })] : [])], spacing: { after: 40 } }));
}
return Packer.toBlob(new Document({ sections: [{ children }] }));
}
export function downloadMarksFile(blob, fileName) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = fileName;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+33
View File
@@ -0,0 +1,33 @@
import { test, expect } from 'vitest';
import { buildMarksDocxBlob, buildMarksModel, marksToMarkdown } from './marksExport.js';
const hl = (id, color, bookIndex, bookName, chapter, verse, text, extra = {}) => ({ id, color, bookIndex, bookName, chapter, verse, text, startOffset: 0, ...extra });
const highlights = [
hl('1', '#bbf7d0', 43, 'John', 3, 16, 'For God so loved the world', { translation: 'BSB' }),
hl('2', '#bbf7d0', 44, 'Acts', 1, 8, 'you will receive power'),
hl('3', '#fef08a', 44, 'Romans', 8, 28, 'all things work together'),
hl('4', '#bbf7d0', 42, 'Luke', 1, 37, 'nothing is impossible with God'),
];
const bookmarks = [{ bookName: 'Psalms', bookIndex: 18, chapter: 23, verse: 1, color: '#bfdbfe' }, { bookName: 'Genesis', bookIndex: 0, chapter: 1, verse: 1, color: '#fef08a' }];
test('highlights are grouped by colour under the names you gave them, in Bible order', () => {
const model = buildMarksModel({ highlights, bookmarks, colorLabels: { '#bbf7d0': 'Promises' } });
expect(model.groups.map((g) => g.title)).toEqual(['Yellow', 'Promises (green)']); // yellow comes first in the picker order
expect(model.groups[1].items.map((i) => i.ref)).toEqual(['Luke 1:37', 'John 3:16', 'Acts 1:8']);
expect(model.bookmarks.map((b) => b.ref)).toEqual(['Genesis 1:1', 'Psalms 23:1']);
});
test('the Markdown lists each mark with its words and the translation', () => {
const md = marksToMarkdown(buildMarksModel({ highlights, bookmarks, colorLabels: {} }), { title: 'Mine', date: new Date(2026, 9, 6) });
expect(md).toContain('# Mine');
expect(md).toContain('## Green');
expect(md).toContain('- **John 3:16** (BSB) — “For God so loved the world”');
expect(md).toContain('## Bookmarks');
expect(md).toContain('- Psalms 23:1 (blue)');
expect(marksToMarkdown({ groups: [], bookmarks: [] })).toContain('Nothing marked yet.');
});
test('the Word file is made', async () => {
const blob = await buildMarksDocxBlob(buildMarksModel({ highlights, bookmarks, colorLabels: {} }));
expect(blob.size).toBeGreaterThan(1000);
});
+43
View File
@@ -0,0 +1,43 @@
// Helpers for present mode: a clock, and how the talk is going against the minutes planned in the outline.
import { flatten } from './outline.js';
export function formatClock(totalSeconds) {
const s = Math.max(0, Math.round(totalSeconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const mm = String(m).padStart(h ? 2 : 1, '0');
return `${h ? `${h}:` : ''}${mm}:${String(sec).padStart(2, '0')}`;
}
// The outline as a list of steps to move through, with the minutes planned so far at the *end* of each step.
export function outlineSteps(tree) {
let planned = 0;
return flatten(tree).map(({ node, depth, label }) => {
const minutes = Number.isFinite(node.minutes) && node.minutes > 0 ? node.minutes : 0;
planned += minutes;
return { id: node.id, label, depth, text: node.text ?? '', ref: node.ref ?? '', note: node.note ?? '', kind: node.kind ?? '', scripture: node.scripture ?? null, attachments: node.attachments ?? [], minutes, plannedEnd: planned };
});
}
// How the talk is going. Each point has a window in the plan: it should start when the earlier points' minutes are
// used up and end when its own are. Being on a point before its window starts means you are ahead; still on it
// after its window ends means you are behind; anywhere in the window is on pace.
// -> { plannedSeconds (when it should start), endSeconds, totalSeconds, diffSeconds (+ ahead, - behind) } or null when no minutes are planned.
export function pacing(steps, index, elapsedSeconds) {
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
if (!total) return null;
const startSeconds = (index > 0 ? steps[index - 1].plannedEnd : 0) * 60;
const endSeconds = (steps[index]?.plannedEnd ?? total) * 60;
let diff = 0;
if (elapsedSeconds < startSeconds) diff = startSeconds - elapsedSeconds;
else if (elapsedSeconds > endSeconds) diff = endSeconds - elapsedSeconds;
return { plannedSeconds: startSeconds, endSeconds, totalSeconds: total * 60, diffSeconds: diff };
}
export const paceText = (p) => {
if (!p) return '';
const d = Math.round(p.diffSeconds);
if (Math.abs(d) < 20) return 'On pace';
return d > 0 ? `${formatClock(d)} ahead` : `${formatClock(-d)} behind`;
};
+30
View File
@@ -0,0 +1,30 @@
import { test, expect } from 'vitest';
import { formatClock, outlineSteps, paceText, pacing } from './present.js';
const n = (id, text, minutes = null, children = []) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children });
const tree = [n('a', 'Intro', 5, [n('a1', 'Hook', 2)]), n('b', 'Body', 15), n('c', 'Close', 5)];
test('the clock reads like a clock', () => {
expect(formatClock(0)).toBe('0:00');
expect(formatClock(65)).toBe('1:05');
expect(formatClock(3725)).toBe('1:02:05');
expect(formatClock(-4)).toBe('0:00');
});
test('the outline becomes steps in reading order with minutes planned so far', () => {
const steps = outlineSteps(tree);
expect(steps.map((s) => `${s.label}${s.text}`)).toEqual(['I.Intro', 'A.Hook', 'II.Body', 'III.Close']);
expect(steps.map((s) => s.plannedEnd)).toEqual([5, 7, 22, 27]);
});
test('pace: ahead before a point\'s window, behind after it, on pace inside it', () => {
const steps = outlineSteps(tree); // Body is planned from 7:00 to 22:00
expect(pacing(steps, 0, 0)).toMatchObject({ plannedSeconds: 0, totalSeconds: 27 * 60 });
expect(pacing(steps, 2, 6 * 60).diffSeconds).toBe(60);
expect(paceText(pacing(steps, 2, 6 * 60))).toBe('1:00 ahead');
expect(paceText(pacing(steps, 2, 9 * 60))).toBe('On pace'); // part-way through Body
expect(paceText(pacing(steps, 2, 25 * 60))).toBe('3:00 behind'); // still on Body after it should have ended
expect(paceText(pacing(steps, 0, 65))).toBe('On pace'); // a minute into a 5-minute intro
expect(pacing(outlineSteps([n('x', 'No minutes')]), 0, 10)).toBeNull();
expect(paceText(null)).toBe('');
});
+1
View File
@@ -93,6 +93,7 @@ export function saveProjectToStorage(project) {
title: updated.title,
lastEdited: updated.lastEdited,
chapterSummary: buildChapterSummary(updated),
series: (updated.series ?? '').trim(),
tags,
};
if (existing >= 0) {
+79
View File
@@ -0,0 +1,79 @@
// What has been read in the Reader: { chapters: { "JHN.3": { n: times read, last: "2026-10-06" } }, days: ["2026-10-06", ...] }.
// Pure helpers for recording it, merging two copies, and turning it into the numbers and pictures on the progress map.
import { bookOptions, CHAPTER_COUNTS } from './bibleUtils.jsx';
export const emptyHistory = () => ({ chapters: {}, days: [] });
const DATE = /^\d{4}-\d{2}-\d{2}$/;
export const chapterKey = (book, chapter) => `${book}.${chapter}`;
export function normalizeHistory(raw) {
const chapters = {};
if (raw?.chapters && typeof raw.chapters === 'object' && !Array.isArray(raw.chapters)) {
for (const [k, v] of Object.entries(raw.chapters)) {
if (/^[1-3A-Z]{3}\.\d{1,3}$/.test(k) && v && typeof v === 'object') chapters[k] = { n: Number.isInteger(v.n) && v.n > 0 ? v.n : 1, last: DATE.test(v.last ?? '') ? v.last : '' };
}
}
const days = Array.isArray(raw?.days) ? [...new Set(raw.days.filter((d) => typeof d === 'string' && DATE.test(d)))].sort() : [];
return { chapters, days };
}
export function mergeHistory(a, b) {
const x = normalizeHistory(a);
const y = normalizeHistory(b);
const chapters = { ...x.chapters };
for (const [k, v] of Object.entries(y.chapters)) {
const mine = chapters[k];
chapters[k] = mine ? { n: Math.max(mine.n, v.n), last: mine.last > v.last ? mine.last : v.last } : v;
}
return { chapters, days: [...new Set([...x.days, ...y.days])].sort() };
}
export const historyEqual = (a, b) => JSON.stringify(normalizeHistory(a)) === JSON.stringify(normalizeHistory(b));
// A chapter read on `date`: counted once per day however long you stay, and the day is marked as a reading day.
export function recordRead(history, book, chapter, date) {
const key = chapterKey(book, chapter);
const was = history.chapters[key];
const alreadyToday = was?.last === date;
const chapters = alreadyToday ? history.chapters : { ...history.chapters, [key]: { n: (was?.n ?? 0) + 1, last: date } };
const days = history.days.includes(date) ? history.days : [...history.days, date].sort();
return chapters === history.chapters && days === history.days ? history : { chapters, days };
}
// Chapters finished in reading plans count as read too: -> Set of "JHN.3" keys
export function planChapterKeys(planProgress, getSchedule) {
const keys = new Set();
for (const p of planProgress ?? []) {
for (const list of Object.values(p.chaptersRead ?? {})) for (const k of list ?? []) keys.add(String(k).toUpperCase());
let schedule;
try { schedule = getSchedule(p.planId) ?? []; } catch { schedule = []; }
for (const day of p.completedDays ?? []) for (const r of schedule[day] ?? []) if (r?.book && r?.chapter) keys.add(chapterKey(r.book, r.chapter));
}
return keys;
}
// -> { books: [{ abbrev, name, read, total, pct, testament }], read, total, pct, ot: {read,total}, nt: {read,total} }
export function progressSummary(history, extraKeys = new Set()) {
const have = new Set([...Object.keys(history.chapters), ...extraKeys]);
const books = bookOptions.map((b, i) => {
const total = CHAPTER_COUNTS[b.abbrev] ?? 0;
let read = 0;
for (let c = 1; c <= total; c += 1) if (have.has(chapterKey(b.abbrev, c))) read += 1;
return { abbrev: b.abbrev, name: b.name, read, total, pct: total ? read / total : 0, testament: i < 39 ? 'ot' : 'nt' };
});
const sum = (list) => list.reduce((acc, b) => ({ read: acc.read + b.read, total: acc.total + b.total }), { read: 0, total: 0 });
const all = sum(books);
return { books, read: all.read, total: all.total, pct: all.total ? all.read / all.total : 0, ot: sum(books.filter((b) => b.testament === 'ot')), nt: sum(books.filter((b) => b.testament === 'nt')) };
}
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
// The last `weeks` weeks as columns of 7 days (Sunday first), ending with the week of `today`.
// -> [[{ date, on, future }]]
export function heatmapWeeks(daySet, today = new Date(), weeks = 26) {
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const startOfThisWeek = new Date(end.getFullYear(), end.getMonth(), end.getDate() - end.getDay());
return Array.from({ length: weeks }, (_, w) => Array.from({ length: 7 }, (__, d) => {
const date = new Date(startOfThisWeek.getFullYear(), startOfThisWeek.getMonth(), startOfThisWeek.getDate() - (weeks - 1 - w) * 7 + d);
return { date: iso(date), on: daySet.has(iso(date)), future: date > end };
}));
}
+55
View File
@@ -0,0 +1,55 @@
import { test, expect } from 'vitest';
import { emptyHistory, heatmapWeeks, historyEqual, mergeHistory, normalizeHistory, planChapterKeys, progressSummary, recordRead } from './readingHistory.js';
test('a chapter is counted once a day, and the day is marked', () => {
let h = recordRead(emptyHistory(), 'JHN', 3, '2026-10-05');
expect(h).toEqual({ chapters: { 'JHN.3': { n: 1, last: '2026-10-05' } }, days: ['2026-10-05'] });
const same = recordRead(h, 'JHN', 3, '2026-10-05');
expect(same).toBe(h); // staying on the page changes nothing
h = recordRead(h, 'JHN', 3, '2026-10-06');
expect(h.chapters['JHN.3']).toEqual({ n: 2, last: '2026-10-06' });
h = recordRead(h, 'ROM', 8, '2026-10-06');
expect(h.days).toEqual(['2026-10-05', '2026-10-06']);
});
test('two copies merge without losing anything', () => {
const a = { chapters: { 'JHN.3': { n: 3, last: '2026-10-01' } }, days: ['2026-10-01'] };
const b = { chapters: { 'JHN.3': { n: 1, last: '2026-10-09' }, 'GEN.1': { n: 1, last: '2026-10-02' } }, days: ['2026-10-02'] };
const m = mergeHistory(a, b);
expect(m.chapters['JHN.3']).toEqual({ n: 3, last: '2026-10-09' });
expect(m.days).toEqual(['2026-10-01', '2026-10-02']);
expect(historyEqual(m, mergeHistory(b, a))).toBe(true);
expect(normalizeHistory({ chapters: { bad: {} }, days: ['x'] })).toEqual(emptyHistory());
});
test('progress is counted per book and for the whole Bible, with plan chapters included', () => {
const h = { chapters: { 'GEN.1': { n: 1, last: '' }, 'GEN.2': { n: 1, last: '' }, 'JHN.3': { n: 1, last: '' } }, days: [] };
const sum = progressSummary(h, new Set(['GEN.3', 'JHN.3', 'REV.22']));
const gen = sum.books.find((b) => b.abbrev === 'GEN');
expect(gen).toMatchObject({ read: 3, total: 50, testament: 'ot' });
expect(sum.books.find((b) => b.abbrev === 'JHN')).toMatchObject({ read: 1, total: 21, testament: 'nt' });
expect(sum.books).toHaveLength(66);
expect(sum.total).toBe(1189);
expect(sum.read).toBe(5); // JHN.3 is in both and counts once
expect(sum.ot.total + sum.nt.total).toBe(1189);
expect(sum.nt.total).toBe(260);
});
test('chapters finished in reading plans come from ticked chapters and completed days', () => {
const schedule = { x: [[{ book: 'JHN', chapter: 1 }, { book: 'JHN', chapter: 2 }], [{ book: 'JHN', chapter: 3 }]] };
const keys = planChapterKeys([{ planId: 'x', completedDays: [0], chaptersRead: { 1: ['JHN.3'] } }], (id) => schedule[id]);
expect([...keys].sort()).toEqual(['JHN.1', 'JHN.2', 'JHN.3']);
expect(planChapterKeys([{ planId: 'gone', completedDays: [0] }], () => { throw new Error('unknown plan'); }).size).toBe(0);
});
test('the calendar is 26 weeks of 7 days ending this week, marking the days read', () => {
const weeks = heatmapWeeks(new Set(['2026-10-06', '2026-10-01']), new Date(2026, 9, 6), 26); // Tuesday
expect(weeks).toHaveLength(26);
expect(weeks.every((w) => w.length === 7)).toBe(true);
const last = weeks.at(-1);
expect(last[0].date).toBe('2026-10-04'); // that week's Sunday
expect(last[2]).toMatchObject({ date: '2026-10-06', on: true, future: false });
expect(last[3].future).toBe(true);
expect(weeks.flat().filter((d) => d.on).map((d) => d.date).sort()).toEqual(['2026-10-01', '2026-10-06']);
expect(weeks[0][0].date).toBe('2026-04-12'); // 25 weeks before
});
+22 -2
View File
@@ -11,15 +11,35 @@ export function tagCounts(index) {
return [...counts].map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
}
export function filterStudies(index, { query = '', tags = [] } = {}) {
// The series names in use, A to Z, with how many studies each has.
export function seriesList(index) {
const counts = new Map();
for (const e of index) { const name = (e.series ?? '').trim(); if (name) counts.set(name, (counts.get(name) ?? 0) + 1); }
return [...counts].map(([name, count]) => ({ name, count })).sort((a, b) => a.name.localeCompare(b.name));
}
// Splits studies into one group per series (A to Z), with the ones in no series last. Keeps their order within a group.
export function groupBySeries(entries) {
const groups = new Map();
for (const e of entries) {
const name = (e.series ?? '').trim();
if (!groups.has(name)) groups.set(name, []);
groups.get(name).push(e);
}
const named = [...groups].filter(([n]) => n).sort(([a], [b]) => a.localeCompare(b)).map(([name, items]) => ({ name, items }));
return groups.has('') ? [...named, { name: '', items: groups.get('') }] : named;
}
export function filterStudies(index, { query = '', tags = [], series = '' } = {}) {
const raw = String(query).trim().toLowerCase();
const tagOnly = raw.startsWith('#');
const q = tagOnly ? raw.slice(1).trim() : raw;
return index.filter((entry) => {
if (series === '__none') { if ((entry.series ?? '').trim()) return false; } else if (series && (entry.series ?? '').trim() !== series) return false;
const own = (entry.tags ?? []).map((t) => t.toLowerCase());
if (tags.length && !tags.every((t) => own.includes(t.toLowerCase()))) return false;
if (!q) return true;
if (own.some((t) => t.includes(q))) return true;
return !tagOnly && ((entry.title ?? '').toLowerCase().includes(q) || (entry.chapterSummary ?? '').toLowerCase().includes(q));
return !tagOnly && ((entry.title ?? '').toLowerCase().includes(q) || (entry.chapterSummary ?? '').toLowerCase().includes(q) || (entry.series ?? '').toLowerCase().includes(q));
});
}
+19
View File
@@ -33,3 +33,22 @@ test('chosen tags must all be present, and combine with the search', () => {
test('tags are counted, most used first', () => {
expect(tagCounts(index)).toEqual([{ tag: 'hope', count: 2 }, { tag: 'grace', count: 1 }, { tag: 'leadership', count: 1 }]);
});
import { groupBySeries, seriesList } from './studyFilter.js';
test('series are listed and studies grouped under them, with unsorted ones last', () => {
const idx = [
{ id: 'a', title: 'A', series: 'Romans' }, { id: 'b', title: 'B', series: '' },
{ id: 'c', title: 'C', series: 'Advent' }, { id: 'd', title: 'D', series: 'Romans' },
];
expect(seriesList(idx)).toEqual([{ name: 'Advent', count: 1 }, { name: 'Romans', count: 2 }]);
expect(groupBySeries(idx).map((g) => `${g.name || '(none)'}:${g.items.map((e) => e.id).join('')}`)).toEqual(['Advent:c', 'Romans:ad', '(none):b']);
expect(groupBySeries([{ id: 'x' }]).map((g) => g.name)).toEqual(['']);
});
test('a series can be chosen as a filter, and the search finds studies by series name', () => {
const idx = [{ id: 'a', title: 'Ch 8', series: 'Romans' }, { id: 'b', title: 'Ch 1' }, { id: 'c', title: 'Gen', series: 'Advent' }];
expect(filterStudies(idx, { series: 'Romans' }).map((e) => e.id)).toEqual(['a']);
expect(filterStudies(idx, { series: '__none' }).map((e) => e.id)).toEqual(['b']);
expect(filterStudies(idx, { query: 'advent' }).map((e) => e.id)).toEqual(['c']);
});
+29 -6
View File
@@ -38,13 +38,36 @@ export const STUDY_TEMPLATES = [
},
];
export const getTemplate = (id) => STUDY_TEMPLATES.find((t) => t.id === id) ?? STUDY_TEMPLATES[0];
export const getTemplate = (id, userTemplates = []) => STUDY_TEMPLATES.find((t) => t.id === id) ?? userTemplates.find((t) => t.id === id) ?? STUDY_TEMPLATES[0];
// The prompt for one note box in a study built from `templateId`, or undefined for the default one.
export const templatePrompt = (templateId, field) => getTemplate(templateId).prompts?.[field];
// A template can be given as an id of a built-in, or as the template itself (a study keeps a copy of the one
// it was made from, so it still works for a co-teacher or on another device that doesn't have it).
const asTemplate = (t) => (t && typeof t === 'object' ? t : getTemplate(t));
// The prompt for one note box in a study built from this template, or undefined for the default one.
export const templatePrompt = (template, field) => asTemplate(template).prompts?.[field];
export const STARTER_FIELDS = ['generalNotes', 'observation', 'interpretation', 'application'];
// Fields a new chunk starts with under this template.
export const templateStarter = (templateId) => {
const starter = getTemplate(templateId).starter;
return starter ? { generalNotes: starter } : {};
export const templateStarter = (template) => {
const t = asTemplate(template);
const starters = { ...(t.starter ? { generalNotes: t.starter } : {}), ...(t.starters ?? {}) };
return Object.fromEntries(STARTER_FIELDS.filter((f) => typeof starters[f] === 'string' && starters[f].trim()).map((f) => [f, starters[f]]));
};
// A template made from the notes written in a chunk: what it asks (the study's current prompts) and the scaffold
// text in each note box. Returns null when there is nothing in the chunk to keep.
export function templateFromChunk({ name, chunk, template }) {
const starters = Object.fromEntries(STARTER_FIELDS.filter((f) => (chunk[f] ?? '').trim()).map((f) => [f, chunk[f].slice(0, 2000)]));
const t = asTemplate(template);
const prompts = Object.fromEntries(['observation', 'interpretation', 'application'].filter((f) => t.prompts?.[f]).map((f) => [f, t.prompts[f]]));
if (!name.trim() || (!Object.keys(starters).length && !Object.keys(prompts).length)) return null;
return {
id: `user-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
name: name.trim().slice(0, 40),
description: 'Your own template',
...(Object.keys(prompts).length ? { prompts } : {}),
...(Object.keys(starters).length ? { starters } : {}),
};
}
+25
View File
@@ -16,3 +16,28 @@ test('templates change the prompts and may pre-fill background notes', () => {
expect(blankChunk(1, 5).generalNotes).toBe('');
expect(blankChunk(1, 5, null, 'devotional').generalNotes).toBe('');
});
import { templateFromChunk } from './studyTemplates.js';
test('a template of your own can be made from what is written in a chunk', () => {
const chunk = { observation: 'Who: \nWhat: ', interpretation: '', application: '', generalNotes: 'Context:\n' };
const made = templateFromChunk({ name: ' My lesson ', chunk, template: 'inductive' });
expect(made.id).toMatch(/^user-/);
expect(made.name).toBe('My lesson');
expect(made.starters).toEqual({ observation: 'Who: \nWhat: ', generalNotes: 'Context:\n' }); // empty boxes are left out
expect(made.prompts.observation).toMatch(/Look before you leap/); // keeps the prompts of the template it came from
expect(templateFromChunk({ name: '', chunk, template: 'blank' })).toBeNull();
expect(templateFromChunk({ name: 'x', chunk: { observation: '', interpretation: '', application: '', generalNotes: '' }, template: 'blank' })).toBeNull();
});
test('new chunks start from your template\'s text in every box it filled', () => {
const def = { id: 'user-1', name: 'Mine', starters: { observation: 'Who:', generalNotes: 'Context:' }, prompts: { application: 'So what?' } };
const c = blankChunk(1, 3, null, def);
expect(c.observation).toBe('Who:');
expect(c.generalNotes).toBe('Context:');
expect(c.interpretation).toBe('');
expect(templatePrompt(def, 'application')).toBe('So what?');
expect(templatePrompt(def, 'observation')).toBeUndefined();
expect(getTemplate('user-1', [def]).name).toBe('Mine');
expect(getTemplate('user-9', [def]).id).toBe('blank');
});