Merge more-study-features: Insights, compare, Finish stage, presenter view and more
Build and Push Docker Image / build-and-push (push) Successful in 5m28s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 13:25:15 -04:00
commit 0781fa3476
63 files changed
+2053 -106

No files matched your search

+14 -12
View File
@@ -140,7 +140,7 @@ export function initDb() {
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
}
// Share links can expire and be protected by a passcode.
for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) {
for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'share_reveal INTEGER', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) {
if (!projectCols.some((c) => c.name === col.split(' ')[0])) db.exec(`ALTER TABLE projects ADD COLUMN ${col}`);
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
@@ -780,7 +780,7 @@ export function setShareToken(id, userId, token) {
/** Revokes a project's share link, scoped to userId. */
export function clearShareToken(id, userId) {
db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId);
db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL, share_reveal = NULL WHERE id = ? AND user_id = ?').run(id, userId);
}
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
@@ -800,14 +800,14 @@ export function clearOutlineShareToken(id, userId) {
// ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ----
const shareCols = (outline) => (outline
? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass' }
: { token: 'share_token', expires: 'share_expires', pass: 'share_pass' });
? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass', reveal: 'NULL' }
: { token: 'share_token', expires: 'share_expires', pass: 'share_pass', reveal: 'share_reveal' });
/** { token, expiresAt, hasPasscode } for the owner's link settings. */
/** { token, expiresAt, hasPasscode, revealAt } for the owner's link settings (revealAt: notes stay hidden until then). */
export function getShareSettings(id, userId, outline = false) {
const c = shareCols(outline);
const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId);
return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass } : null;
const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass, ${c.reveal} AS revealAt FROM projects WHERE id = ? AND user_id = ?`).get(id, userId);
return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass, revealAt: row.revealAt ?? null } : null;
}
/** The stored passcode hash of a link (kept when only the expiry is changed). */
@@ -816,18 +816,20 @@ export function getSharePassHash(id, userId, outline = false) {
return db.prepare(`SELECT ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId)?.pass ?? null;
}
/** Sets expiry (ms timestamp or null) and passcode hash (or null) on an existing link. */
export function setShareSettings(id, userId, { expiresAt, passHash }, outline = false) {
/** Sets expiry (ms timestamp or null), passcode hash (or null) and, for a study link, when its notes appear (or null). */
export function setShareSettings(id, userId, { expiresAt, passHash, revealAt }, outline = false) {
const c = shareCols(outline);
return db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0;
const changed = db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0;
if (changed && !outline && revealAt !== undefined) db.prepare('UPDATE projects SET share_reveal = ? WHERE id = ? AND user_id = ?').run(revealAt ?? null, id, userId);
return changed;
}
/** Public lookup for a link: the project plus the link's rules, or null for an unknown token. */
export function getSharedByToken(token, outline = false) {
const c = shareCols(outline);
const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash FROM projects WHERE ${c.token} = ?`).get(token);
const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash, ${c.reveal} AS revealAt FROM projects WHERE ${c.token} = ?`).get(token);
if (!row) return null;
try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null }; } catch { return null; }
try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null, revealAt: row.revealAt ?? null }; } catch { return null; }
}
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
+11 -9
View File
@@ -2,7 +2,7 @@
import { randomUUID } from 'crypto';
import { acceptEditInvite, clearOutlineShareToken, clearShareToken, countOpenEditInvites, createEditInvite, getSharePassHash, getShareSettings, getSharedByToken, isProjectOwner, listCollaborators, removeCollaborator, setOutlineShareToken, setShareSettings, setShareToken } from '../db.js';
import { requireAuth } from '../auth.js';
import { checkShareAccess, expiryFromDays, hashPasscode } from '../shareLinks.js';
import { checkShareAccess, expiryFromDays, hashPasscode, hideUntilReveal, revealFromInput } from '../shareLinks.js';
import { buildOutlinePayload } from '../outlineShare.js';
import { TOO_MANY, isLimited, recordFailure } from '../limiter.js';
@@ -16,7 +16,7 @@ export function registerSharingRoutes(app) {
try {
const settings = getShareSettings(req.params.id, req.session.userId);
if (!settings) return res.status(404).json({ error: 'Project not found.' });
res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode });
res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode, revealAt: settings.revealAt });
} catch (err) {
console.error('GET /api/projects/:id/share error:', err);
res.status(500).json({ error: 'Failed to load share status.' });
@@ -37,8 +37,10 @@ export function registerSharingRoutes(app) {
// a blank passcode field means "keep it"; null clears; a string sets
const current = getSharePassHash(req.params.id, userId, outline);
const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current;
setShareSettings(req.params.id, userId, { expiresAt, passHash }, outline);
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash });
// only a study link can hide its notes until a time; a past or empty time turns hiding off
const revealAt = outline ? null : ('revealAt' in body ? revealFromInput(body.revealAt) : settings.revealAt);
setShareSettings(req.params.id, userId, { expiresAt, passHash, revealAt }, outline);
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash, revealAt });
} catch (err) {
console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err);
res.status(500).json({ error: 'Failed to enable sharing.' });
@@ -123,14 +125,14 @@ export function registerSharingRoutes(app) {
res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 });
return null;
}
return found.project;
return found;
}
// GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed)
app.get('/api/share-outline/:token', async (req, res) => {
try {
const project = await openSharedLink(req, res, true);
if (project) res.json(buildOutlinePayload(project));
const found = await openSharedLink(req, res, true);
if (found) res.json(buildOutlinePayload(found.project));
} catch (err) {
console.error('GET /api/share-outline/:token error:', err);
res.status(500).json({ error: 'Failed to load the shared outline.' });
@@ -140,8 +142,8 @@ export function registerSharingRoutes(app) {
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed)
app.get('/api/share/:token', async (req, res) => {
try {
const project = await openSharedLink(req, res, false);
if (project) res.json(project);
const found = await openSharedLink(req, res, false);
if (found) res.json(hideUntilReveal(found.project, found.revealAt));
} catch (err) {
console.error('GET /api/share/:token error:', err);
res.status(500).json({ error: 'Failed to load shared project.' });
+24
View File
@@ -25,3 +25,27 @@ export async function checkShareAccess({ expiresAt, passHash, supplied }, now =
if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' };
return { ok: true };
}
// "Hide my notes until…": before the reveal time a shared study shows only the passages and the discussion questions
// (no notes, outline, script, words or group notes), and says when the rest appears. The strip is done here, on the
// server, so the hidden text never reaches the browser. After the time (or with none set) the study is sent whole.
const OPEN_CHUNK_FIELDS = ['id', 'startVerse', 'endVerse', 'spilloverEndVerse', 'episodeNumber', 'episodeTitle', 'discussionQuestions'];
export function hideUntilReveal(project, revealAt, now = Date.now()) {
if (!revealAt || revealAt <= now || !project || typeof project !== 'object') return project;
return {
id: project.id, title: project.title, translation: project.translation, series: project.series, outline: [],
hiddenUntil: revealAt,
chapters: (Array.isArray(project.chapters) ? project.chapters : []).map((ch) => ({
book: ch.book, bookAbbrev: ch.bookAbbrev, chapter: ch.chapter, verses: ch.verses,
chunks: (Array.isArray(ch.chunks) ? ch.chunks : []).map((chunk) => Object.fromEntries(OPEN_CHUNK_FIELDS.filter((f) => f in chunk).map((f) => [f, chunk[f]]))),
})),
};
}
// A reveal time from the browser -> ms in the future (up to a year), or null for "no hiding"
export function revealFromInput(value, now = Date.now()) {
const n = Number(value);
if (!value || !Number.isFinite(n) || n <= now) return null;
return Math.min(n, now + 365 * 86_400_000);
}
+33
View File
@@ -31,3 +31,36 @@ test('an expired link is reported as expired even before the passcode is checked
const passHash = await hashPasscode('x');
expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 });
});
import { hideUntilReveal, revealFromInput } from './shareLinks.js';
const study = () => ({
id: 's1', title: 'John', translation: 'BSB', outline: [{ id: 'n', text: 'Point' }],
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved.' }],
chunks: [{ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?', observation: 'secret', interpretation: 'secret', application: 'secret', finalScript: 'secret', generalNotes: 'secret', greekWords: [{ id: 'w' }], teachBack: 'secret', recalls: [{ text: 'secret' }] }] }],
});
test('before the reveal time a shared study keeps the passages and questions and drops every note', () => {
const now = 1_000;
const out = hideUntilReveal(study(), now + 5_000, now);
expect(out.hiddenUntil).toBe(now + 5_000);
expect(out.outline).toEqual([]);
expect(out.chapters[0].verses).toHaveLength(1);
expect(out.chapters[0].chunks[0]).toEqual({ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?' });
expect(JSON.stringify(out)).not.toContain('secret');
});
test('after the reveal time, or with none set, the whole study is sent', () => {
const full = study();
expect(hideUntilReveal(full, 500, 1_000)).toBe(full);
expect(hideUntilReveal(full, null, 1_000)).toBe(full);
});
test('a reveal time must be in the future, and no more than a year away', () => {
const now = 1_000_000;
expect(revealFromInput(null, now)).toBeNull();
expect(revealFromInput(now - 1, now)).toBeNull();
expect(revealFromInput('nope', now)).toBeNull();
expect(revealFromInput(now + 60_000, now)).toBe(now + 60_000);
expect(revealFromInput(now + 10 * 365 * 86_400_000, now)).toBe(now + 365 * 86_400_000);
});
+13 -4
View File
@@ -11,6 +11,7 @@ import Tooltips from './components/Tooltips.jsx';
import { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js';
import { isAudienceWindow } from './utils/presentSync.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useHistoryNav } from './hooks/useHistoryNav.js';
import { usePrayers } from './hooks/usePrayers.js';
@@ -37,6 +38,8 @@ const ImportPage = lazy(() => import('./pages/ImportPage.jsx'));
const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
const AudienceView = lazy(() => import('./components/AudienceView.jsx'));
const InsightsPage = lazy(() => import('./pages/InsightsPage.jsx'));
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx'));
const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx'));
@@ -166,8 +169,8 @@ const AppInner = () => {
// The browser tab and history show where you are ("Romans 8 · Bible Study", "Luke — Outline · Bible Study")
useEffect(() => {
const names = {
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Export',
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal',
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Finish',
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal', insights: 'Insights',
};
let page = names[currentPage] ?? '';
if (currentPage === 'reader') {
@@ -355,12 +358,13 @@ const AppInner = () => {
setProject((current) => (current ? updater(current) : current));
};
const updateChunk = (chunkId, patch) => {
// `touch: false` saves a change without counting it as an edit (so it doesn't reset "last edited" for recalls and summaries).
const updateChunk = (chunkId, patch, { touch = true } = {}) => {
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, editedAt: Date.now() } : c)),
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, ...(touch ? { editedAt: Date.now() } : {}) } : c)),
})),
}));
};
@@ -584,6 +588,9 @@ function AppRouter() {
// A read-only outline link: no sign-in, nothing editable
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
// The second window for presenting on a projector: no sign-in, it only shows what the presenter window sends.
if (isAudienceWindow()) return <Suspense fallback={null}><AudienceView /></Suspense>;
if (sharedViewToken) {
if (needsPasscode) return <SharedPasscodeForm dark error={passcodeError} busy={checking} onSubmit={submitPasscode} />;
if (sharedError) {
@@ -606,6 +613,7 @@ function AppRouter() {
return (
<div className="min-h-dvh bg-slate-100">
<div className="border-b border-slate-200 bg-slate-900 px-4 py-3 text-center text-xs text-slate-300">
{sharedProject.hiddenUntil ? `Notes appear ${new Date(sharedProject.hiddenUntil).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}. For now: the passages and questions. ` : ''}
🔗 Read-only shared view —{' '}
<a href="/" className="underline hover:text-white">go to Bible Study Project</a>
</div>
@@ -629,6 +637,7 @@ function AppRouter() {
if (currentPage === 'prayer') return <PrayerPage />;
if (currentPage === 'reader') return <ReaderPage />;
if (currentPage === 'import') return <ImportPage />;
if (currentPage === 'insights') return <InsightsPage />;
if (currentPage === 'study') return <StudyPage />;
if (currentPage === 'outline') return <OutlinePage />;
if (currentPage === 'script') return <ScriptPage />;
+56
View File
@@ -0,0 +1,56 @@
// The audience window for two-screen presenting: drag it to the projector or second display and press F (or click)
// for full screen. It only draws what the presenter window sends, which never includes private notes.
import { useEffect, useState } from 'react';
import StepFace from './StepFace.jsx';
import { openChannel } from '../utils/presentSync.js';
export default function AudienceView() {
const [state, setState] = useState(null);
const [ended, setEnded] = useState(false);
const [hint, setHint] = useState(true);
useEffect(() => {
document.title = 'Audience display';
const channel = openChannel();
if (!channel) return undefined;
channel.onmessage = (e) => {
if (e.data?.type === 'state') { setState(e.data); setEnded(false); }
else if (e.data?.type === 'end') setEnded(true);
};
channel.postMessage({ type: 'hello' }); // ask a presenter already running to send what it is showing
return () => channel.close();
}, []);
useEffect(() => {
const fullscreen = () => { if (document.fullscreenElement) document.exitFullscreen?.(); else document.documentElement.requestFullscreen?.().catch(() => {}); };
const onKey = (e) => { if (e.key === 'f' || e.key === 'F') fullscreen(); };
window.addEventListener('keydown', onKey);
const timer = window.setTimeout(() => setHint(false), 8000);
return () => { window.removeEventListener('keydown', onKey); window.clearTimeout(timer); };
}, []);
const dark = state?.dark ?? true;
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
const fontSize = `${state?.fontPx ?? 40}px`;
let body;
if (!state || ended) {
body = <p className="text-center text-lg opacity-60">{ended ? 'The presentation has ended.' : 'Waiting for the presenter…'}</p>;
} else if (state.blank) {
body = null;
} else if (state.mode === 'outline' && state.step) {
body = <StepFace step={state.step} index={state.index} total={state.total} fontSize={fontSize} />;
} else {
body = (
<div className="text-center font-serif" style={{ fontSize }}>
<h1 className="font-semibold">{state.title}</h1>
{state.subtitle && <p className="mt-3 text-[0.55em] opacity-70">{state.subtitle}</p>}
</div>
);
}
return (
<div className={`fixed inset-0 flex cursor-none items-center justify-center overflow-hidden px-[6vw] py-[6dvh] ${state?.blank ? 'bg-black' : colors}`} onClick={() => document.documentElement.requestFullscreen?.().catch(() => {})}>
{body}
{hint && !state?.blank && <p className="fixed bottom-3 left-0 right-0 text-center font-sans text-xs opacity-40">Press F or click for full screen</p>}
</div>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Export → Co-teachers: let other accounts edit this study with you. You make a single-use invite link;
// Finish → 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';
+9 -3
View File
@@ -1,14 +1,15 @@
// Export → Lesson handout: a printable page for each session (or one session), for a group to follow along.
// Finish → 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';
import { HANDOUT_LAYOUTS, 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 [layout, setLayout] = useState('standard');
const [blocked, setBlocked] = useState(false);
if (sessions.length === 0) return null;
const go = () => setBlocked(!printHandout(project, which === 'all' ? null : [which], { answerLines: lines }));
const go = () => setBlocked(!printHandout(project, which === 'all' ? null : [which], { answerLines: lines, layout }));
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>
@@ -20,6 +21,11 @@ export default function HandoutExport({ project }) {
{sessions.map((s) => <option key={s.chunk.id} value={s.chunk.id}>{s.number}. {s.reference}</option>)}
</select>
</label>
<label className="text-xs text-[var(--r-sub)]">Layout
<select value={layout} onChange={(e) => setLayout(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)]">
{HANDOUT_LAYOUTS.map((l) => <option key={l.id} value={l.id}>{l.label}</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>
+61
View File
@@ -0,0 +1,61 @@
// Finish → Import notes: bring notes from a Word or Markdown file into this study, matched to its chunks by passage.
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useToast } from './Toasts.jsx';
import { applyImportedNotes, parseNotesText } from '../utils/importNotes.js';
export default function ImportNotes() {
const { project, updateProject } = useApp();
const { toast } = useToast();
const [file, setFile] = useState('');
const [sections, setSections] = useState(null); // null until a file is read
const [own, setOwn] = useState(true);
const [error, setError] = useState('');
const choose = async (e) => {
const f = e.target.files?.[0];
e.target.value = '';
setError(''); setSections(null);
if (!f) return;
try {
let text;
if (/\.docx$/i.test(f.name)) {
const { default: mammoth } = await import('mammoth');
text = (await mammoth.extractRawText({ arrayBuffer: await f.arrayBuffer() })).value;
} else text = await f.text();
const found = parseNotesText(text);
setFile(f.name);
setSections(found);
if (found.length === 0) setError('No passage headings with Observe / Interpret / Apply notes were found in that file. Start each section with a heading like “John 3:16–18”.');
} catch { setError('That file couldn’t be read.'); }
};
const preview = sections ? applyImportedNotes(project, sections, { own }) : null;
const add = () => {
updateProject((current) => applyImportedNotes(current, sections, { own }).project);
toast(`Added notes to ${preview.matched.length} chunk${preview.matched.length === 1 ? '' : 's'}`);
setSections(null);
};
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Import notes">
<h2 className="text-sm font-medium">Import notes</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Bring in notes from a Word (.docx), Markdown or text file. Start each section with a passage heading (“John 3:16–18”), then label the notes “Observe:”, “Interpret:”, “Apply:”, “Background:” or “Questions:”. Notes you’ve already written are kept; imported text is added below them.</p>
<label className="inline-block cursor-pointer rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Choose a file
<input type="file" accept=".docx,.md,.markdown,.txt,text/markdown,text/plain" className="hidden" onChange={choose} />
</label>
{error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{error}</p>}
{preview && sections.length > 0 && (
<div className="mt-3 space-y-2 text-sm">
<p className="text-xs text-[var(--r-sub)]">{file}: {sections.length} section{sections.length === 1 ? '' : 's'} found.</p>
<ul className="space-y-1 text-xs">
{preview.matched.map((m) => <li key={m.heading}>✓ {m.heading}: {m.fields.join(', ')}</li>)}
{preview.unmatched.map((h) => <li key={h} className="text-[var(--r-sub)]">No chunk for {h}, skipped</li>)}
</ul>
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} className="accent-[var(--r-accent)]" /> These notes are my own writing (untick if they came from AI or another source)</label>
<button type="button" onClick={add} disabled={preview.matched.length === 0} className="rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] disabled:opacity-40">Add to this study</button>
</div>
)}
</section>
);
}
+19 -5
View File
@@ -11,21 +11,27 @@ export const expiryText = (expiresAt, now = Date.now()) => {
return `Expires ${new Date(expiresAt).toLocaleDateString()} (${days} day${days === 1 ? '' : 's'} left)`;
};
export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) {
// "2026-10-18T09:00" in the person's own time zone <-> ms
const toLocalInput = (ms) => { if (!ms) return ''; const d = new Date(ms); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; };
// `showReveal` adds "Hide my notes until…" (study links only; an outline link never shows notes).
export default function LinkRules({ expiresAt, hasPasscode, busy, onSave, showReveal = false, revealAt = null }) {
const [days, setDays] = useState('keep'); // 'keep' | '' | '1' | '7' | '30'
const [passcode, setPasscode] = useState('');
const dirty = days !== 'keep' || passcode.trim() !== '';
const [reveal, setReveal] = useState(null); // null = leave as is; '' = show everything now; else a local date-time
const dirty = days !== 'keep' || passcode.trim() !== '' || reveal !== null;
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(''); }
if (reveal !== null) options.revealAt = reveal === '' ? null : new Date(reveal).getTime();
if (await onSave(options)) { setDays('keep'); setPasscode(''); setReveal(null); }
};
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>
<p className="text-xs text-[var(--r-sub)]">{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}{showReveal && revealAt && revealAt > Date.now() ? ` · Your notes appear ${new Date(revealAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}` : ''}</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`}>
@@ -36,10 +42,18 @@ export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) {
<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>
{showReveal && (
<label className="text-xs text-[var(--r-sub)]">Hide my notes until
<input type="datetime-local" value={reveal ?? toLocalInput(revealAt > Date.now() ? revealAt : null)} onChange={(e) => setReveal(e.target.value)} className={`${field} mt-1 block`} />
</label>
)}
{showReveal && (reveal ?? toLocalInput(revealAt > Date.now() ? revealAt : null)) && (
<button type="button" onClick={() => setReveal('')} className="text-xs text-[var(--r-sub)] hover:underline">Show everything now</button>
)}
<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>
<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.{showReveal ? ' Until the time you set, the group sees only the passages and discussion questions; your notes, outline and scripts stay on the server.' : ''}</p>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
// On a study card: whether its Bible text works without a connection, and a button to fix that.
import { DownloadProgress } from './OfflineSave.jsx';
import { offlineStatus } from '../utils/offline.js';
export default function OfflineBadge({ project, offline }) {
if (!project) return null;
const status = offlineStatus(project, offline.saved);
const label = 'mt-1 text-xs';
if (offline.job) return <div className="mt-2"><DownloadProgress job={offline.job} onCancel={offline.cancel} /></div>;
if (status.state === 'ready') return <p className={`${label} text-[var(--r-accent)]`} title="Your notes and the Bible text for this study open without a connection">✓ Works offline</p>;
if (status.state === 'licensed') {
return <p className={`${label} text-[var(--r-sub)]`} title={`The ${status.translation} licence doesn’t allow saving its text for offline use`}>Notes work offline; {status.translation} text needs a connection</p>;
}
const next = status.missing[0];
return (
<p className={`${label} flex flex-wrap items-center gap-x-2 text-[var(--r-sub)]`}>
<span>Needs a connection for {status.missing.map((b) => b.name).join(', ')}</span>
<button type="button" onClick={() => offline.saveBook(status.translation, next.abbrev, next.name)} className="text-[var(--r-accent)] hover:underline">Save {next.name} for offline</button>
</p>
);
}
+100 -9
View File
@@ -1,8 +1,15 @@
// 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.
//
// With a projector or second screen: "Audience window" opens a second window to drag over. It shows only the point,
// its passage and quoted verse; your speaker notes, the script, the timer and the plan stay on this screen. This
// screen then becomes a presenter view (what the audience sees, your notes, the next point). On one screen, notes are
// hidden unless you turn them on, so a mirrored display never shows them by accident. B blacks out the audience screen.
import { useCallback, useEffect, useRef, useState } from 'react';
import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js';
import { audienceState, audienceUrl, openChannel } from '../utils/presentSync.js';
import StepFace from './StepFace.jsx';
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';
@@ -16,10 +23,43 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
const [speed, setSpeed] = useState(30); // pixels per second
const [index, setIndex] = useState(0);
const scroller = useRef(null);
const [showNotes, setShowNotes] = useState(() => { try { return localStorage.getItem('present-notes-here') === 'on'; } catch { return false; } });
const [audienceOpen, setAudienceOpen] = useState(false); // an audience window is connected: this screen is the presenter's
const [blank, setBlank] = useState(false); // the audience screen is blacked out
const [audSize, setAudSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-aud-size')) || 48)); } catch { return 5; } });
const [popupBlocked, setPopupBlocked] = useState(false);
const audienceWin = useRef(null);
const channel = useRef(null);
const latest = useRef(null);
const steps = mode === 'outline' ? outlineSteps(tree) : [];
const step = steps[index];
const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null;
// Two screens: keep the audience window in step with this one, answering a window that has just opened.
const audience = audienceState({ title, subtitle, mode, step, index, total: steps.length, dark, fontPx: SIZES[audSize], blank });
latest.current = audience;
useEffect(() => {
const ch = openChannel();
channel.current = ch;
if (!ch) return undefined;
ch.onmessage = (e) => { if (e.data?.type === 'hello') { setAudienceOpen(true); ch.postMessage(latest.current); } };
return () => { ch.postMessage({ type: 'end' }); ch.close(); channel.current = null; };
}, []);
useEffect(() => { channel.current?.postMessage(audience); }); // every change: the point, the colours, blank
useEffect(() => {
if (!audienceOpen) return undefined;
const t = window.setInterval(() => { if (audienceWin.current?.closed) { audienceWin.current = null; setAudienceOpen(false); setBlank(false); } }, 1000);
return () => window.clearInterval(t);
}, [audienceOpen]);
const openAudience = () => {
if (!channel.current) return;
const win = window.open(audienceUrl(), 'bible-study-audience', 'popup,width=1280,height=720');
setPopupBlocked(!win);
if (win) { audienceWin.current = win; setAudienceOpen(true); }
};
const toggleNotes = () => setShowNotes((on) => { const next = !on; try { localStorage.setItem('present-notes-here', next ? 'on' : 'off'); } catch { /* storage unavailable */ } return next; });
const audBigger = (d) => setAudSize((i) => { const next = Math.min(SIZES.length - 1, Math.max(0, i + d)); try { localStorage.setItem('present-aud-size', String(SIZES[next])); } catch { /* storage unavailable */ } return next; });
// the clock
useEffect(() => {
if (!running) return undefined;
@@ -68,13 +108,20 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
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 === 'b' || e.key === 'B') && audienceOpen) setBlank((b) => !b);
if ((e.key === 'n' || e.key === 'N') && !audienceOpen && mode === 'outline') toggleNotes();
if (e.key === '+' || e.key === '=') bigger(1);
if (e.key === '-') bigger(-1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [mode, go, onClose]);
}, [mode, go, onClose, audienceOpen]);
const banner = audienceOpen && (
<p role="status" className={`shrink-0 px-4 py-1.5 text-center text-xs ${blank ? 'bg-red-500/25' : 'bg-emerald-500/15'}`}>
{blank ? 'The audience screen is blank. Press B to bring it back.' : mode === 'script' ? 'The audience window is connected. It shows only the title; your script stays on this screen.' : 'The audience window is connected. It shows the points and verses, never your notes.'}
</p>
);
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;
@@ -102,29 +149,73 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
</label>
</div>
)}
{typeof BroadcastChannel !== 'undefined' ? (
<div className="flex items-center gap-2" role="group" aria-label="Second screen">
{audienceOpen ? (
<>
<button type="button" onClick={() => setBlank((b) => !b)} aria-pressed={blank} className={btn} title="Black out the audience screen (B)">{blank ? 'Show audience' : 'Blank audience'}</button>
{mode === 'outline' && (
<span className="flex items-center gap-1" role="group" aria-label="Audience text size">
<button type="button" onClick={() => audBigger(-1)} disabled={audSize === 0} aria-label="Smaller audience text" className={btn}>Screen A−</button>
<button type="button" onClick={() => audBigger(1)} disabled={audSize === SIZES.length - 1} aria-label="Larger audience text" className={btn}>A+</button>
</span>
)}
</>
) : (
<>
<button type="button" onClick={openAudience} className={btn} title="Opens a second window for a projector or other screen. It shows the points, never your notes.">Audience window</button>
{mode === 'outline' && <button type="button" onClick={toggleNotes} aria-pressed={showNotes} className={btn} title="Speaker notes stay hidden on this screen unless you turn them on (N)">{showNotes ? 'Hide notes' : 'Show notes'}</button>}
</>
)}
</div>
) : null}
<button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button>
</div>
{mode === 'script' ? (
<>
{banner}
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8dvh]" 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: '50dvh' }} 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">
{popupBlocked && <p role="alert" className="shrink-0 bg-amber-500/20 px-4 py-1.5 text-center text-xs">Your browser blocked the audience window. Allow pop-ups for this site, then try again.</p>}
{banner}
{audienceOpen && step ? (
// Presenter view: what the audience sees, your private notes, and what comes next
<div className="grid min-h-0 flex-1 gap-0 md:grid-cols-[3fr_2fr]">
<div className="min-h-0 overflow-y-auto border-current/15 px-[3vw] py-[4dvh] md:border-r">
<p className="mb-3 font-sans text-[11px] uppercase tracking-[0.16em] opacity-50">The audience sees</p>
<StepFace step={step} index={index} total={steps.length} fontSize={`${SIZES[Math.max(0, size - 3)]}px`} minutes={step.minutes} />
</div>
<div className="min-h-0 overflow-y-auto px-[3vw] py-[4dvh]">
<p className="mb-3 font-sans text-[11px] uppercase tracking-[0.16em] text-amber-500">Only you see this</p>
{step.note.trim()
? <p className="whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.45 }}>{step.note}</p>
: <p className="font-sans text-sm opacity-50">No speaker notes for this point. Add them under the point’s “⋯” details on the Outline page.</p>}
{steps[index + 1] && (
<div className="mt-8 border-t border-current/15 pt-4">
<p className="font-sans text-[11px] uppercase tracking-[0.16em] opacity-50">Next</p>
<p className="mt-1 font-serif text-xl"><span className="mr-2 opacity-60">{steps[index + 1].label}</span>{steps[index + 1].text || '(untitled)'}</p>
{steps[index + 1].minutes > 0 && <p className="font-sans text-xs opacity-60">{steps[index + 1].minutes} min</p>}
</div>
)}
</div>
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6dvh]">
{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>
<>
<StepFace step={step} index={index} total={steps.length} fontSize={fontSize} minutes={step.minutes} note={showNotes ? step.note : ''} />
{showNotes && step.note && <p className="mx-auto mt-2 max-w-[46em] font-sans text-[11px] uppercase tracking-[0.16em] text-amber-500">Speaker notes are showing on this screen (N to hide)</p>}
</>
) : <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>
+78 -3
View File
@@ -1,9 +1,10 @@
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, afterEach } from 'vitest';
import { vi, test, expect, afterEach, beforeEach } from 'vitest';
import PresentMode from './PresentMode.jsx';
afterEach(() => vi.useRealTimers());
beforeEach(() => localStorage.clear());
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
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' })];
@@ -11,7 +12,7 @@ test('the outline is stepped through a point at a time, with the keyboard or the
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.queryByText('Say hello')).not.toBeInTheDocument(); // speaker notes are private by default
expect(screen.getByText(/Point 1 of 2/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled();
await user.keyboard('{ArrowRight}');
@@ -58,3 +59,77 @@ test('Escape and Close both leave, and an empty script says what to do', async (
await user.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(2);
});
// A stand-in for the browser's BroadcastChannel that records what the presenter window sends
class FakeChannel {
static instances = [];
constructor() { this.sent = []; FakeChannel.instances.push(this); }
postMessage(m) { this.sent.push(m); }
close() {}
}
const withChannel = () => { FakeChannel.instances = []; vi.stubGlobal('BroadcastChannel', FakeChannel); };
const last = () => FakeChannel.instances[0].sent.filter((m) => m.type === 'state').at(-1);
test('on one screen the speaker notes stay hidden until you turn them on', async () => {
const user = userEvent.setup();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
expect(screen.queryByText('Say hello')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Show notes' }));
expect(screen.getByText('Say hello')).toBeInTheDocument();
expect(localStorage.getItem('present-notes-here')).toBe('on');
await user.click(screen.getByRole('button', { name: 'Hide notes' }));
expect(screen.queryByText('Say hello')).not.toBeInTheDocument();
});
test('with an audience window the notes appear only on the presenter screen, never in what is sent', async () => {
withChannel();
vi.stubGlobal('open', vi.fn(() => ({ closed: false })));
const user = userEvent.setup();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
await user.click(screen.getByRole('button', { name: 'Audience window' }));
expect(window.open).toHaveBeenCalledWith(expect.stringContaining('?audience=1'), 'bible-study-audience', expect.any(String));
expect(screen.getByText('Only you see this')).toBeInTheDocument();
expect(screen.getByText('Say hello')).toBeInTheDocument(); // the presenter's own screen
expect(screen.getByText(/never your notes/i)).toBeInTheDocument();
expect(last()).toMatchObject({ mode: 'outline', index: 0, total: 2, blank: false });
expect(last().step.text).toBe('Welcome');
const everything = JSON.stringify(FakeChannel.instances[0].sent);
expect(everything).not.toContain('Say hello');
expect(screen.getByText('Next')).toBeInTheDocument(); // the preview of the next point
await user.keyboard('{ArrowRight}');
expect(last().step.text).toBe('The gospel');
});
test('a window that opens later is brought up to date, and B blacks the audience screen out', async () => {
withChannel();
const user = userEvent.setup();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
const channel = FakeChannel.instances[0];
act(() => channel.onmessage({ data: { type: 'hello' } })); // someone opened the audience address by hand
expect(await screen.findByText('Only you see this')).toBeInTheDocument();
expect(channel.sent.at(-1).step.text).toBe('Welcome');
await user.keyboard('b');
expect(last().blank).toBe(true);
expect(screen.getByText(/audience screen is blank/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Show audience' }));
expect(last().blank).toBe(false);
});
test('in script mode the audience is only ever sent the title', async () => {
withChannel();
vi.stubGlobal('open', vi.fn(() => ({ closed: false })));
const user = userEvent.setup();
render(<PresentMode mode="script" title="Romans 8" script="My private script text" onClose={() => {}} />);
await user.click(screen.getByRole('button', { name: 'Audience window' }));
expect(last()).toMatchObject({ mode: 'script', title: 'Romans 8', step: null });
expect(JSON.stringify(FakeChannel.instances[0].sent)).not.toContain('private script');
expect(screen.getByText(/only the title/i)).toBeInTheDocument();
});
test('the presentation ending tells the audience window', () => {
withChannel();
const { unmount } = render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
const channel = FakeChannel.instances[0];
unmount();
expect(channel.sent.at(-1)).toEqual({ type: 'end' });
});
+1 -1
View File
@@ -1,4 +1,4 @@
// Export → Schedule: the study's sessions (one per chunk) as a calendar file, starting on a date you pick.
// Finish → 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';
+4
View File
@@ -22,6 +22,10 @@ const BY_PAGE = {
],
study: [
['← →', 'Previous / next chunk'],
['1 2 3', 'Write in Observe / Interpret / Apply'],
['Esc', 'Leave a note box (then keys work again)'],
['C W X P', 'Commentary / Word study / Cross-refs / People & places'],
['V', 'Compare passages or translations'],
],
};
+30
View File
@@ -0,0 +1,30 @@
// Finish → Slides: a printable slide deck for a session or the whole study (Save as PDF in the print dialog).
import { useState } from 'react';
import { sessionsFor } from '../utils/handout.js';
import { printSlides } from '../utils/slides.js';
export default function SlidesExport({ project }) {
const sessions = sessionsFor(project);
const [which, setWhich] = useState('all');
const [dark, setDark] = useState(false);
const [blocked, setBlocked] = useState(false);
if (sessions.length === 0) return null;
const go = () => setBlocked(!printSlides(project, which === 'all' ? null : [which], { dark }));
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Slides">
<h2 className="text-sm font-medium">Slides</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">A 16:9 slide for each session’s passage, each outline point (with the verse it quotes) and the discussion questions. Choose “Save as PDF” in the print dialog, then open it in any slide viewer or project it from the PDF. It isn’t a PowerPoint file.</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}</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={dark} onChange={(e) => setDark(e.target.checked)} className="accent-[var(--r-accent)]" /> Dark slides</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 slides.</p>}
</section>
);
}
+15
View File
@@ -0,0 +1,15 @@
// One outline point as it appears on screen: the point, its passage and quoted verse, and what it carries.
// Used for the whole screen in present mode, for the presenter's preview, and on the audience window. `note` and
// `minutes` are only passed where the speaker is the one looking.
export default function StepFace({ step, index, total, fontSize, minutes = null, note = '' }) {
return (
<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 {total}{minutes ? ` · ${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>}
{note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{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>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Export → Earlier versions: the copies the server kept of this study as you worked. Look at what is different
// Finish → Earlier versions: the copies the server kept of this study as you worked. Look at what is different
// from now, and put one back. The version you replace is kept, so putting one back can be undone too.
import { useCallback, useEffect, useState } from 'react';
import { getStudyVersion, listStudyVersions } from '../syncService.js';
+6 -1
View File
@@ -16,6 +16,7 @@ export default function WeeklyRecall() {
const { projectIndex, saveChunkRecall, openStudyChunk } = useApp();
const [step, setStep] = useState('card'); // card -> write -> compare -> done
const [text, setText] = useState('');
const [gaps, setGaps] = useState('');
const pick = useMemo(
() => pickRecall(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)),
[projectIndex],
@@ -24,7 +25,7 @@ export default function WeeklyRecall() {
if (!pick) return null;
const finish = (rating) => {
saveChunkRecall(pick.projectId, pick.chunk.id, { at: Date.now(), text: text.trim(), rating });
saveChunkRecall(pick.projectId, pick.chunk.id, { at: Date.now(), text: text.trim(), rating, gaps: gaps.trim() });
setStep('done');
};
@@ -63,6 +64,10 @@ export default function WeeklyRecall() {
))}
</div>
</div>
<label className="mt-3 block text-xs text-[var(--r-sub)]">What did you miss or get wrong? <span className="opacity-70">(one per line; collected in Insights)</span>
<textarea aria-label="What slipped" value={gaps} onChange={(e) => setGaps(e.target.value)} rows={2}
className="mt-1 w-full resize-y 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" />
</label>
<p className="mt-3 text-xs text-[var(--r-sub)]">How did you do?</p>
<div className="mt-1 flex flex-wrap gap-2">
{RATE.map((r) => (
+2 -1
View File
@@ -4,7 +4,7 @@ import { useApp } from '../../context/AppContext.js';
import { useToast } from '../Toasts.jsx';
import { DownloadProgress } from '../OfflineSave.jsx';
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
import { backupFileName, buildBackup, restoreProjects, validateBackup } from '../../utils/backup.js';
import { backupFileName, buildBackup, markBackedUp, restoreProjects, validateBackup } from '../../utils/backup.js';
import { saveRemoteProject } 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';
@@ -38,6 +38,7 @@ export default function DataSection() {
link.click();
link.remove();
URL.revokeObjectURL(url);
markBackedUp();
toast(`Backup downloaded: ${plural(backup.projects.length, 'study', 'studies')} and your reading marks`);
};
+214
View File
@@ -0,0 +1,214 @@
// Compare two passages side by side — your notes and the verses — for parallels (the Gospels), repeated themes,
// or the same chapter studied twice. Pick any chunk from any of your studies to set beside the one you're on.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { loadProjectById } from '../../utils/projectStorage.js';
import { formatChunkReference, getChunkVerseEntries } from '../../utils/bibleUtils.jsx';
import { READER_TRANSLATIONS, fetchChapter, translationName } from '../../utils/bibleText.js';
import { alignVerses, differencesFor, markRanges, translationSpans } from '../../utils/compareTranslations.js';
import { ownShare, pastedWords } from '../../utils/ownWork.js';
import { usePasteCheck } from './PastePrompt.jsx';
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
// Every chunk in your studies, as a pick-list entry that remembers where it sits.
function choices(projectIndex, currentProject) {
const projects = projectIndex.map((e) => (e.id === currentProject?.id ? currentProject : loadProjectById(e.id))).filter(Boolean);
return projects.flatMap((project) => project.chapters.flatMap((chapter, chapterIndex) => chapter.chunks.map((chunk) => ({
key: `${project.id}:${chunk.id}`, project, chapterIndex, chunk,
label: `${formatChunkReference(project, chapterIndex, chunk, '–')} · ${project.title}`,
}))));
}
function Column({ entry }) {
if (!entry) return <p className="p-4 text-sm text-[var(--r-sub)]">Pick a passage to compare.</p>;
const verses = getChunkVerseEntries(entry.project, entry.chapterIndex, entry.chunk);
return (
<div className="min-w-0 space-y-3 p-4">
<h3 className="font-medium">{entry.label}</h3>
<div className="max-h-48 space-y-1 overflow-y-auto rounded-xl bg-[var(--r-bar)] p-3 font-serif text-[13px] leading-relaxed">
{verses.map((v) => <p key={v.number}><sup className="mr-1 text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}
</div>
{FIELDS.map(([f, label]) => (
<div key={f}>
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{label}</p>
<p className="whitespace-pre-line font-serif text-[14px] leading-relaxed">{entry.chunk[f]?.trim() || <span className="text-[var(--r-sub)]">Nothing written.</span>}</p>
</div>
))}
</div>
);
}
const OIA = [
['observation', 'Observe', 'What does the text say?'],
['interpretation', 'Interpret', 'What does it mean?'],
['application', 'Apply', 'What do I do with it?'],
];
// The chunk's own Observe / Interpret / Apply notes, editable right here while the translations are side by side.
// They are the same notes as on the Study page, and long pastes are tagged the same way.
function OiaBoxes({ chunk, updateChunk }) {
const share = ownShare(chunk);
const pasteCheck = usePasteCheck(chunk, updateChunk);
return (
<section aria-label="Your notes" className="border-t border-[var(--r-line)] px-4 pb-6 pt-4">
<p className="mb-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes{share !== null ? ` · ${share}% your own words` : ''}</p>
<div className="grid gap-4 md:grid-cols-3">
{OIA.map(([field, label, hint]) => {
const pasted = pastedWords(chunk, field);
return (
<label key={field} className="flex flex-col text-xs text-[var(--r-sub)]">
<span className="mb-1 flex items-baseline justify-between"><span><span className="text-sm font-medium text-[var(--r-text)]">{label}</span> {hint}</span>{pasted > 0 && <span className="rounded-full bg-[var(--r-hl)] px-2 py-0.5 text-[var(--r-text)]">{pasted} pasted</span>}</span>
<textarea aria-label={label} value={chunk[field] ?? ''} rows={9}
onPaste={pasteCheck.onPaste(field)}
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })}
className="min-h-[10rem] w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 font-serif text-[15px] leading-7 text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
{pasteCheck.prompt(field)}
</label>
);
})}
</div>
</section>
);
}
// One chunk's verses in several translations, a row per verse. Free translations load straight away; licensed ones
// come through the server and may be unavailable (no key set), which shows as a note instead of text.
function TranslationsView({ project, chunkEntry, updateChunk }) {
const [highlight, setHighlight] = useState(() => { try { return localStorage.getItem('compare-highlight') !== 'off'; } catch { return true; } });
const setHl = (on) => { setHighlight(on); try { localStorage.setItem('compare-highlight', on ? 'on' : 'off'); } catch { /* storage unavailable */ } };
const spans = useMemo(() => (chunkEntry ? translationSpans(project, chunkEntry.chapterIndex, chunkEntry.chunk) : []), [project, chunkEntry]);
const spanKey = spans.map((s) => `${s.book}${s.chapter}:${s.from}-${s.to}`).join(',');
const [picked, setPicked] = useState(() => {
try { const saved = JSON.parse(localStorage.getItem('compare-translations')); if (Array.isArray(saved) && saved.length) return saved; } catch { /* storage unavailable */ }
return [project?.translation ?? 'BSB', 'NET'];
});
const [loaded, setLoaded] = useState({}); // id -> { status: 'loading'|'ok'|'error', chapters, copyright, error }
const toggle = (id) => setPicked((cur) => {
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
try { localStorage.setItem('compare-translations', JSON.stringify(next)); } catch { /* storage unavailable */ }
return next;
});
// The sheet is modal, so the passage can't change while it's open; results are only dropped after it closes.
const alive = useRef(true);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
picked.filter((id) => !loaded[id]).forEach((id) => {
setLoaded((cur) => ({ ...cur, [id]: { status: 'loading' } }));
Promise.all(spans.map((span) => fetchChapter(id, span.bookAbbrev, Number(span.chapter))
.then((res) => [`${span.book}|${span.chapter}`, res])))
.then((results) => alive.current && setLoaded((cur) => ({
...cur,
[id]: { status: 'ok', chapters: Object.fromEntries(results.map(([k, r]) => [k, r.verses])), copyright: results.map(([, r]) => r.copyright).find(Boolean) ?? '' },
})))
.catch((err) => { if (alive.current) setLoaded((cur) => ({ ...cur, [id]: { status: 'error', error: err.message || 'Unable to load.' } })); });
});
}, [picked, spanKey]); // eslint-disable-line react-hooks/exhaustive-deps
const shown = picked.filter((id) => loaded[id]?.status === 'ok');
const rows = alignVerses(spans, Object.fromEntries(shown.map((id) => [id, loaded[id].chapters])));
const cols = { gridTemplateColumns: `repeat(${Math.max(1, shown.length)}, minmax(0, 1fr))` };
return (
<div className="space-y-4 p-4">
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Translations to compare">
{READER_TRANSLATIONS.map((t) => {
const on = picked.includes(t.id);
return (
<button key={t.id} type="button" aria-pressed={on} onClick={() => toggle(t.id)} title={t.name}
className={`rounded-full border px-3 py-1 text-xs ${on ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{t.id}</button>
);
})}
</div>
{shown.length > 1 && (
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={highlight} onChange={(e) => setHl(e.target.checked)} className="accent-[var(--r-accent)]" /> Highlight the words that differ from {shown[0]}</label>
)}
{picked.map((id) => loaded[id]?.status === 'error' && (
<p key={id} role="status" className="text-xs text-[var(--r-sub)]">{id} isn’t available right now: {loaded[id].error}</p>
))}
{picked.some((id) => loaded[id]?.status === 'loading') && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
{shown.length === 0 && !picked.some((id) => !loaded[id] || loaded[id].status === 'loading') && <p className="text-sm text-[var(--r-sub)]">Pick two or more translations above.</p>}
{shown.length > 0 && (
<div className="space-y-3">
<div className="hidden gap-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid" style={cols}>
{shown.map((id) => <span key={id}>{translationName(id)}</span>)}
</div>
{rows.map((row) => {
const diffs = highlight && shown.length > 1 ? differencesFor(row.texts, shown) : {};
const note = chunkEntry.chunk.verseNotes?.[row.noteKey] ?? '';
return (
<div key={row.key} className="border-t border-[var(--r-line)] pt-3">
<div className="grid gap-x-4 gap-y-1 md:[grid-template-columns:var(--cols)]" style={{ '--cols': cols.gridTemplateColumns }}>
{shown.map((id) => (
<p key={id} className="font-serif text-[14px] leading-relaxed">
<sup className="mr-1 font-sans text-[var(--r-accent)]">{row.label}</sup>
<span className="mr-1 font-sans text-[10px] uppercase tracking-wide text-[var(--r-sub)] md:hidden">{id}</span>
{row.texts[id] == null ? <span className="text-[var(--r-sub)]">—</span>
: markRanges(row.texts[id], diffs[id]).map((part, i) => (part.mark ? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-inherit">{part.text}</mark> : <span key={i}>{part.text}</span>))}
</p>
))}
</div>
<input aria-label={`Note on verse ${row.label}`} value={note} placeholder={`Note on verse ${row.label}`}
onChange={(e) => updateChunk(chunkEntry.chunk.id, { verseNotes: { ...(chunkEntry.chunk.verseNotes ?? {}), [row.noteKey]: e.target.value } })}
className="mt-1.5 w-full rounded-lg border border-transparent bg-transparent px-2 py-1 text-xs text-[var(--r-text)] placeholder:text-[var(--r-sub)] placeholder:opacity-60 hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none" />
</div>
);
})}
<div className="space-y-1 border-t border-[var(--r-line)] pt-3 text-[11px] leading-snug text-[var(--r-sub)]">
{shown.map((id) => {
const t = READER_TRANSLATIONS.find((x) => x.id === id);
return t?.notice || loaded[id].copyright ? <p key={id}>{loaded[id].copyright || t.notice}</p> : null;
})}
</div>
</div>
)}
</div>
);
}
export default function CompareSheet({ currentChunkId, onClose }) {
const { projectIndex, project, updateChunk } = useApp();
// Other studies are read from storage once per list change; the open study comes straight from state so typing stays quick.
const others = useMemo(() => choices(projectIndex.filter((e) => e.id !== project?.id), null), [projectIndex, project?.id]);
const here = useMemo(() => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => chapter.chunks.map((chunk) => ({
key: `${project.id}:${chunk.id}`, project, chapterIndex, chunk, label: `${formatChunkReference(project, chapterIndex, chunk, '–')} · ${project.title}`,
}))), [project]);
const mine = here.find((c) => c.chunk.id === currentChunkId);
const all = useMemo(() => [...here, ...others], [here, others]);
const [mode, setMode] = useState('passage'); // 'passage' | 'translations'
const [otherKey, setOtherKey] = useState('');
const other = all.find((c) => c.key === otherKey);
const tab = (id) => `rounded-full border px-3 py-1 text-xs ${mode === id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
return (
<div role="dialog" aria-modal="true" aria-label="Compare passages" className="fixed inset-0 z-[70] flex flex-col bg-[var(--r-bg)] text-[var(--r-text)]">
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-2.5">
<h2 className="mr-auto text-sm font-medium sm:mr-0">Compare</h2>
<div role="tablist" aria-label="What to compare" className="flex gap-1.5">
<button type="button" role="tab" aria-selected={mode === 'passage'} onClick={() => setMode('passage')} className={tab('passage')}>Another passage</button>
<button type="button" role="tab" aria-selected={mode === 'translations'} onClick={() => setMode('translations')} className={tab('translations')}>Translations</button>
</div>
{mode === 'passage' && (
<select aria-label="Compare with" value={otherKey} onChange={(e) => setOtherKey(e.target.value)}
className="order-last min-w-0 basis-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm sm:order-none sm:basis-0 sm:flex-1">
<option value="">Choose a passage to set beside this one…</option>
{all.filter((c) => c.key !== mine?.key).map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
</select>
)}
<button type="button" onClick={onClose} className="ml-auto rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5">Close</button>
</div>
{mode === 'passage' ? (
<div className="grid min-h-0 flex-1 overflow-y-auto md:grid-cols-2 md:divide-x md:divide-[var(--r-line)]">
<Column entry={mine} />
<Column entry={other} />
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto">
<h3 className="px-4 pt-4 font-medium">{mine?.label}</h3>
<TranslationsView project={project} chunkEntry={mine} updateChunk={updateChunk} />
{mine && <OiaBoxes chunk={mine.chunk} updateChunk={updateChunk} />}
</div>
)}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
// "What changed since my first draft": your Observe / Interpret / Apply notes now, with the words added or
// reworded since the copy kept when you first opened commentary. It shows whether commentary changed your thinking.
import { diffVerses } from '../../utils/textDiff.js';
import { markRanges } from '../../utils/compareTranslations.js';
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
const count = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
export default function FirstDraft({ chunk }) {
const draft = chunk.firstDraft;
if (!draft) return null;
const rows = FIELDS.map(([f, label]) => {
const then = draft[f] ?? '';
const now = chunk[f] ?? '';
return { f, label, then, now, same: then.trim() === now.trim(), parts: markRanges(now, diffVerses(then, now).b) };
}).filter((r) => r.then.trim() || r.now.trim());
const changed = rows.filter((r) => !r.same).length;
return (
<details className="rounded-xl border border-[var(--r-line)] p-3 text-xs text-[var(--r-sub)]">
<summary className="cursor-pointer">First draft, before commentary <span className="opacity-70">· {new Date(draft.at).toLocaleDateString()} · {changed === 0 ? 'nothing changed since' : `${changed} of ${rows.length} notes changed since`}</span></summary>
<div className="mt-3 space-y-3">
{rows.length === 0 && <p>You opened commentary before writing anything.</p>}
{rows.map((r) => (
<div key={r.f}>
<p className="font-medium text-[var(--r-text)]">{r.label} <span className="font-normal text-[var(--r-sub)]">· {count(r.then)} words then, {count(r.now)} now</span></p>
{r.same
? <p className="mt-0.5">Unchanged.</p>
: <p className="mt-0.5 whitespace-pre-line font-serif text-[13px] leading-relaxed text-[var(--r-text)]">{r.parts.map((p, i) => (p.mark ? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-inherit">{p.text}</mark> : <span key={i}>{p.text}</span>))}</p>}
{!r.same && r.then.trim() && <details className="mt-1"><summary className="cursor-pointer">Show the first draft</summary><p className="mt-1 whitespace-pre-line font-serif text-[13px] leading-relaxed">{r.then}</p></details>}
</div>
))}
<p className="opacity-70">Highlighted words are new or reworded since the draft.</p>
</div>
</details>
);
}
+61 -9
View File
@@ -1,8 +1,12 @@
// 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 { useEffect, useRef, useState } from 'react';
import SessionNotes from './SessionNotes.jsx';
import FirstDraft from './FirstDraft.jsx';
import { usePasteCheck } from './PastePrompt.jsx';
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
import { ownShare, ownWords, pastedWords, withPaste, clearPastes } from '../../utils/ownWork.js';
import { ownShare, ownWords, pastedWords, clearPastes } from '../../utils/ownWork.js';
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
const SECTIONS = [
@@ -17,11 +21,25 @@ 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, saveTemplate, notify }) {
export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify, onPray, focusRequest }) {
const [open, setOpen] = useState(() => {
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
});
const [more, setMore] = useState(false);
// A keyboard jump (1 / 2 / 3 on the Study page): open that note and put the cursor in it.
const areas = useRef({});
const wanted = useRef(null);
useEffect(() => {
if (!focusRequest) return;
wanted.current = focusRequest.field;
setOpen(focusRequest.field);
}, [focusRequest]);
useEffect(() => {
if (wanted.current && wanted.current === open) {
const el = areas.current[open];
if (el) { el.focus(); const end = el.value.length; el.setSelectionRange(end, end); wanted.current = null; }
}
});
const [tplName, setTplName] = useState('');
const [tplOpen, setTplOpen] = useState(false);
const [tagInput, setTagInput] = useState('');
@@ -38,9 +56,25 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase()));
const share = ownShare(chunk);
const keepPaste = (field) => (e) => {
const next = withPaste(chunk, field, e.clipboardData?.getData('text'));
if (next) updateChunk(chunk.id, { aiPastes: next });
const pasteCheck = usePasteCheck(chunk, updateChunk);
// Earlier versions of one note: kept when you start and stop editing it; "History" lists them with Restore.
const [historyField, setHistoryField] = useState(null);
const [versions, setVersions] = useState([]);
useEffect(() => { setHistoryField(null); }, [chunk.id]);
const keepVersion = (field) => {
const list = recordNoteVersion(chunk.id, field, chunk[field]);
if (historyField === field) setVersions(list);
};
const openHistory = (field) => {
if (historyField === field) { setHistoryField(null); return; }
recordNoteVersion(chunk.id, field, chunk[field]);
setVersions(listNoteVersions(chunk.id, field));
setHistoryField(field);
};
const restore = (field, text) => {
recordNoteVersion(chunk.id, field, chunk[field]); // so restoring can itself be undone
updateChunk(chunk.id, { [field]: text });
setVersions(listNoteVersions(chunk.id, field));
};
return (
@@ -72,15 +106,31 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
</button>
{isOpen && (
<div className="px-4 pb-4">
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
onPaste={keepPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
<textarea ref={(el) => { areas.current[field] = el; }} aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
onFocus={() => keepVersion(field)} onBlur={() => keepVersion(field)} onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
{pasteCheck.prompt(field)}
<button type="button" onClick={() => openHistory(field)} aria-expanded={historyField === field} className="mt-1.5 mr-3 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]">History</button>
{historyField === field && (
<ul aria-label={`${label} history`} className="mt-1.5 space-y-1.5 rounded-xl border border-[var(--r-line)] p-2 text-xs">
{versions.length === 0 && <li className="text-[var(--r-sub)]">No earlier versions yet. They are saved as you edit.</li>}
{versions.map((v) => (
<li key={v.at} className="flex items-baseline justify-between gap-3">
<span className="min-w-0"><span className="text-[var(--r-sub)]">{new Date(v.at).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} · {words(v.text)} words · </span><span className="font-serif">{v.text.length > 70 ? `${v.text.slice(0, 70)}…` : v.text}</span></span>
<button type="button" onClick={() => restore(field, v.text)} disabled={v.text === chunk[field]} className="shrink-0 text-[var(--r-accent)] hover:underline disabled:opacity-40 disabled:no-underline">Restore</button>
</li>
))}
</ul>
)}
{pasted > 0 && (
<p className="mt-1.5 text-xs text-[var(--r-sub)]">
{pasted} word{pasted === 1 ? '' : 's'} here were pasted, so they don't count as your own work.{' '}
<button type="button" onClick={() => updateChunk(chunk.id, { aiPastes: clearPastes(chunk, field) })} className="text-[var(--r-accent)] hover:underline">That’s mine</button>
</p>
)}
<div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
{field === 'application' && onPray && (
<button type="button" onClick={onPray} className="mt-1.5 mr-3 text-xs text-[var(--r-accent)] hover:underline">Pray about this →</button>
)}
<div className="mt-1.5 inline-block"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
</div>
)}
</div>
@@ -106,6 +156,8 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
<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>
<FirstDraft chunk={chunk} />
<SessionNotes chunk={chunk} updateChunk={updateChunk} />
{saveTemplate && (
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
{!tplOpen ? (
+42
View File
@@ -0,0 +1,42 @@
// When a long block of text is pasted into a note, ask where it came from. Pastes are remembered as "not typed by
// me" straight away (so forgetting to answer never counts as your own work); saying you wrote it takes that back.
import { useEffect, useState } from 'react';
import { withPaste, withoutPaste, PASTE_MIN_WORDS } from '../../utils/ownWork.js';
const LABELS = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply' };
const wordsIn = (s) => s.trim().split(/\s+/).filter(Boolean).length;
export function usePasteCheck(chunk, updateChunk) {
const [pending, setPending] = useState(null); // { field, text }
useEffect(() => { setPending(null); }, [chunk.id]);
const onPaste = (field) => (e) => {
const text = e.clipboardData?.getData('text') ?? '';
const next = withPaste(chunk, field, text);
if (!next) return;
updateChunk(chunk.id, { aiPastes: next });
setPending({ field, text: text.trim() });
};
const wroteIt = () => {
if (pending) updateChunk(chunk.id, { aiPastes: withoutPaste(chunk, pending.field, pending.text) });
setPending(null);
};
const prompt = (field) => (pending && pending.field === field ? <PastePrompt field={field} words={wordsIn(pending.text)} onKeep={() => setPending(null)} onWroteIt={wroteIt} /> : null);
return { onPaste, prompt };
}
function PastePrompt({ field, words, onKeep, onWroteIt }) {
const btn = 'rounded-full border border-[var(--r-text)] px-3 py-1 text-xs hover:bg-black/5';
return (
<div role="group" aria-label="Where did this paste come from?" className="mt-1.5 rounded-xl border border-[var(--r-accent)] bg-[var(--r-hl)] p-3 text-xs">
<p className="font-medium text-[var(--r-text)]">You pasted {words} words into {LABELS[field] ?? 'this note'}. Is this from AI?</p>
<p className="mt-0.5 text-[var(--r-sub)]">Pasted text isn’t counted as your own work. Choose “I wrote it” if it came from you.</p>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={onKeep} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)]`}>Yes, from AI</button>
<button type="button" onClick={onKeep} className={btn}>No, a book or website</button>
<button type="button" onClick={onWroteIt} className={btn}>No, I wrote it</button>
</div>
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Pastes under {PASTE_MIN_WORDS} words aren’t asked about.</p>
</div>
);
}
+22 -3
View File
@@ -1,12 +1,13 @@
// Right-hand pane of the Study stage: commentary, word study, cross-references and
// people & places, all driven by the selected chunk.
import { useState } from 'react';
import { useEffect, useState } from 'react';
import CommentaryChunkTab from './CommentaryChunkTab.jsx';
import WordStudyTab from './WordStudyTab.jsx';
import CrossRefsTab from './CrossRefsTab.jsx';
import PeopleChunkTab from './PeopleChunkTab.jsx';
import IqChunkAids from '../iq/IqChunkAids.jsx';
import { useIqAvailable } from '../../hooks/useIq.js';
import { OBSERVE_FIRST_MIN, observeFirstDone, ownWords } from '../../utils/ownWork.js';
export const RESOURCE_TABS = [
{ id: 'commentary', label: 'Commentary' },
@@ -20,6 +21,17 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
const iq = useIqAvailable();
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
if (tab === 'aids' && !iq) tab = 'commentary';
// Commentary-type tabs wait for your own Observe notes (per chunk, until you choose to look anyway).
const [unlocked, setUnlocked] = useState(() => new Set());
useEffect(() => { setUnlocked(new Set()); }, [chunk.id]);
const held = (tab === 'commentary' || tab === 'aids') && !observeFirstDone(chunk) && !unlocked.has(chunk.id);
// The first time commentary or study aids are on screen for a chunk, keep a copy of your notes as they were,
// so you can later see what changed after reading others (shown under "More" in the notes).
const seeing = (tab === 'commentary' || tab === 'aids') && !held;
useEffect(() => {
if (!seeing || chunk.firstDraft) return;
updateChunk(chunk.id, { firstDraft: { at: Date.now(), observation: chunk.observation ?? '', interpretation: chunk.interpretation ?? '', application: chunk.application ?? '' } }, { touch: false });
}, [seeing, chunk.id, chunk.firstDraft]); // eslint-disable-line react-hooks/exhaustive-deps
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
return (
<div className="flex min-h-0 flex-1 flex-col">
@@ -32,11 +44,18 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
))}
</div>
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{tab === 'commentary' && <CommentaryChunkTab resources={resources} chunk={chunk} project={project} chapterIndex={chapterIndex} updateChunk={updateChunk} notify={notify} />}
{held && (
<div role="note" className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 text-sm">
<p className="font-medium">Observe first</p>
<p className="mt-1 text-xs text-[var(--r-sub)]">Write what you notice in the text before reading what others say. Commentary opens after {OBSERVE_FIRST_MIN} words of your own Observe notes ({ownWords(chunk, 'observation')} so far).</p>
<button type="button" onClick={() => setUnlocked((u) => new Set(u).add(chunk.id))} className="mt-2 text-xs text-[var(--r-accent)] hover:underline">Show it anyway</button>
</div>
)}
{!held && tab === 'commentary' && <CommentaryChunkTab resources={resources} chunk={chunk} project={project} chapterIndex={chapterIndex} updateChunk={updateChunk} notify={notify} />}
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
{tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
{!held && tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
</div>
</div>
);
+13 -1
View File
@@ -3,8 +3,9 @@
import { useState } from 'react';
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord }) {
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) {
const [openVerse, setOpenVerse] = useState(null);
const [editingNote, setEditingNote] = useState(null);
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
return (
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
@@ -20,6 +21,17 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
</button>
<span>{v.text}</span>
{onVerseNote && (
<button type="button" onClick={() => setEditingNote(editingNote === `${v.chapter}:${v.number}` ? null : `${v.chapter}:${v.number}`)}
aria-label={`Note on verse ${v.number}`} title="Note on this verse"
className={`ml-1.5 align-baseline font-sans text-[0.7em] ${verseNotes[`${v.chapter}:${v.number}`] ? 'text-[var(--r-accent)]' : 'text-[var(--r-sub)] opacity-40 hover:opacity-100'}`}>✎</button>
)}
{(verseNotes[`${v.chapter}:${v.number}`] || editingNote === `${v.chapter}:${v.number}`) && (
<textarea aria-label={`Verse ${v.number} note`} value={verseNotes[`${v.chapter}:${v.number}`] ?? ''} rows={2} autoFocus={editingNote === `${v.chapter}:${v.number}`}
onChange={(e) => onVerseNote?.(`${v.chapter}:${v.number}`, e.target.value)}
placeholder="Your note on this verse"
className="mt-1 block w-full resize-y rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 font-sans text-[13px] leading-snug text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
)}
{open && words && (
<div dir={isHebrew ? 'rtl' : 'ltr'} className="mt-2 flex flex-wrap gap-2 rounded-xl bg-[var(--r-bg)] p-2.5 font-sans">
{words.map((w, i) => {
+42
View File
@@ -0,0 +1,42 @@
// The "More" area's session-facing notes: a teach-it-back summary, who came, what the group said, and follow-ups.
import { makeId } from '../../utils/bibleUtils.jsx';
const areaCls = 'mt-1 w-full resize-y 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)]';
export default function SessionNotes({ chunk, updateChunk }) {
const followUps = chunk.followUps ?? [];
const setFollowUps = (next) => updateChunk(chunk.id, { followUps: next });
const add = (text) => { const t = text.trim(); if (t) setFollowUps([...followUps, { id: makeId(), text: t, done: false }]); };
return (
<div className="space-y-4">
<label className="block text-xs text-[var(--r-sub)]">
Teach it back <span className="opacity-70">(explain the passage in three sentences, as you would to someone else)</span>
<textarea value={chunk.teachBack ?? ''} rows={3} onChange={(e) => updateChunk(chunk.id, { teachBack: e.target.value })} className={areaCls} />
</label>
<label className="block text-xs text-[var(--r-sub)]">
Who came
<input value={chunk.attendees ?? ''} onChange={(e) => updateChunk(chunk.id, { attendees: e.target.value })} placeholder="Names, separated by commas"
className="mt-1 w-full 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" />
</label>
<label className="block text-xs text-[var(--r-sub)]">
What the group said
<textarea value={chunk.groupNotes ?? ''} rows={3} onChange={(e) => updateChunk(chunk.id, { groupNotes: e.target.value })} className={areaCls} />
</label>
<div>
<p className="text-xs text-[var(--r-sub)]">Follow-ups</p>
<ul className="mt-1 space-y-1">
{followUps.map((f) => (
<li key={f.id} className="flex items-center gap-2 text-sm">
<input type="checkbox" aria-label={`Done: ${f.text}`} checked={!!f.done} onChange={() => setFollowUps(followUps.map((x) => (x.id === f.id ? { ...x, done: !x.done } : x)))} className="accent-[var(--r-accent)]" />
<span className={f.done ? 'text-[var(--r-sub)] line-through' : ''}>{f.text}</span>
<button type="button" aria-label={`Remove follow-up: ${f.text}`} onClick={() => setFollowUps(followUps.filter((x) => x.id !== f.id))} className="ml-auto text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
</li>
))}
</ul>
<input aria-label="Add a follow-up" placeholder="Something to follow up on, then Enter"
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(e.target.value); e.target.value = ''; } }}
className="mt-1.5 w-full 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" />
</div>
</div>
);
}
+5 -5
View File
@@ -10,7 +10,7 @@ export const STAGES = [
{ id: 'study', label: 'Study' },
{ id: 'outline', label: 'Outline' },
{ id: 'script', label: 'Script' },
{ id: 'export', label: 'Export' },
{ id: 'export', label: 'Finish' },
];
const THEME_ORDER = ['paper', 'sepia', 'dark'];
@@ -28,21 +28,21 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
<div className="safe-top z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3">
<HomeButton onClick={goHome} />
<div className="min-w-0 max-w-[16rem] truncate text-sm font-medium">{project?.title ?? ''}</div>
<nav aria-label="Study stages" className="mx-auto flex gap-0.5">
<div className="hidden min-w-0 max-w-[16rem] truncate text-sm font-medium sm:block">{project?.title ?? ''}</div>
<nav aria-label="Study stages" className="flex min-w-0 flex-1 gap-0.5 overflow-x-auto sm:justify-center [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{STAGES.map((s) => {
const disabled = s.id !== 'setup' && (!project || noChunks);
return (
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
title={disabled ? 'Add a chunk in Chunks first' : undefined}
onClick={() => setCurrentPage(s.id)}
className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
className={`shrink-0 rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
{s.label}
</button>
);
})}
</nav>
<div className="flex min-w-[7rem] items-center justify-end gap-2 text-xs text-[var(--r-sub)]" aria-live="polite">
<div className="flex shrink-0 items-center justify-end sm:min-w-[7rem] gap-2 text-xs text-[var(--r-sub)]" aria-live="polite">
{loading ? <span>Loading…</span>
: saveStatus ? <span className="text-[var(--r-accent)]">{saveStatus}</span>
: syncStatus === 'syncing' ? <span>Syncing…</span>
+5 -4
View File
@@ -6,14 +6,15 @@ export function useShareLink(projectId) {
const [shareToken, setShareToken] = useState(null);
const [expiresAt, setExpiresAt] = useState(null);
const [hasPasscode, setHasPasscode] = useState(false);
const [revealAt, setRevealAt] = useState(null);
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); };
const apply = (data) => { setShareToken(data.shareToken ?? null); setExpiresAt(data.expiresAt ?? null); setHasPasscode(!!data.hasPasscode); setRevealAt(data.revealAt ?? null); };
useEffect(() => {
setShareToken(null); setExpiresAt(null); setHasPasscode(false); setShareError('');
setShareToken(null); setExpiresAt(null); setHasPasscode(false); setRevealAt(null); setShareError('');
if (!projectId) return undefined;
let active = true;
getShareStatus(projectId).then((result) => {
@@ -26,7 +27,7 @@ export function useShareLink(projectId) {
? `${window.location.origin}${window.location.pathname}?share=${shareToken}`
: '';
// Turns the link on, or changes its rules: { expiresInDays?: number | null, passcode?: string | null }
// Turns the link on, or changes its rules: { expiresInDays?: number | null, passcode?: string | null, revealAt?: number | null }
const enable = async (options = {}) => {
setShareBusy(true);
setShareError('');
@@ -52,5 +53,5 @@ export function useShareLink(projectId) {
});
};
return { shareToken, shareUrl, expiresAt, hasPasscode, shareBusy, shareCopied, shareError, enable, revoke, copy };
return { shareToken, shareUrl, expiresAt, hasPasscode, revealAt, shareBusy, shareCopied, shareError, enable, revoke, copy };
}
+9 -3
View File
@@ -1,6 +1,8 @@
// The Export stage: take the finished study out as a file, or share it read-only.
// The Finish stage: everything for when a study is ready to use: files, sharing, handouts, slides, importing notes, schedule, co-teachers and earlier versions.
import { useApp } from '../context/AppContext.js';
import { useShareLink } from '../hooks/useShareLink.js';
import SlidesExport from '../components/SlidesExport.jsx';
import ImportNotes from '../components/ImportNotes.jsx';
import HandoutExport from '../components/HandoutExport.jsx';
import SessionSchedule from '../components/SessionSchedule.jsx';
import VersionHistory from '../components/VersionHistory.jsx';
@@ -30,7 +32,7 @@ export default function ExportPage() {
<StudioShell stage="export">
<main className="mx-auto max-w-2xl space-y-5 px-4 py-8">
<div>
<h1 className="text-xl font-medium">Export</h1>
<h1 className="text-xl font-medium">Finish</h1>
<p className="mt-1 text-sm text-[var(--r-sub)]">{allChunks.length} chunk{allChunks.length === 1 ? '' : 's'} · {filled} script{filled === 1 ? '' : 's'} written</p>
</div>
@@ -52,7 +54,7 @@ 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} />
<LinkRules expiresAt={share.expiresAt} hasPasscode={share.hasPasscode} busy={share.shareBusy} onSave={share.enable} showReveal revealAt={share.revealAt} />
{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>
@@ -63,6 +65,10 @@ export default function ExportPage() {
<HandoutExport project={project} />
<SlidesExport project={project} />
<ImportNotes />
<SessionSchedule project={project} />
<CoTeachers projectId={project?.id} sharedBy={project && sharedStudies?.[project.id]} onLeave={() => { deleteProject(project.id); setCurrentPage('home'); }} />
+20 -1
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
import BookCover from '../components/BookCover.jsx';
import WeeklyRecall from '../components/WeeklyRecall.jsx';
import OfflineBadge from '../components/OfflineBadge.jsx';
import { useOfflineSave } from '../hooks/useOfflineSave.js';
import RecallHistory from '../components/RecallHistory.jsx';
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
@@ -14,6 +16,7 @@ import {
formatRelativeDate,
loadProjectIndex,
} from '../utils/bibleUtils.jsx';
import { backupAge, lastBackupAt } from '../utils/backup.js';
import { loadProjectById } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
@@ -38,6 +41,8 @@ export default function HomePage() {
audioState,
audioProgress,
openImportProject,
setCurrentPage,
openBackup,
openNewProject,
pullLatestFromServer,
resumeProject,
@@ -52,6 +57,9 @@ export default function HomePage() {
handleSeekAudioTo,
} = useApp();
const narrators = useLazyNarrators(audioNarrator);
const offline = useOfflineSave();
// Each study's books and translation, read once per list change (the badge itself follows what's saved).
const studyBooks = useMemo(() => Object.fromEntries(projectIndex.map((e) => [e.id, loadProjectById(e.id)])), [projectIndex]);
const [homeSearch, setHomeSearch] = useState('');
const [homeSort, setHomeSort] = useState(() => {
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
@@ -130,6 +138,7 @@ export default function HomePage() {
active="studies"
actions={(
<>
<button type="button" onClick={() => setCurrentPage('insights')} className={linkBtn} title="This week, themes, words, what slipped, follow-ups">Insights</button>
<button type="button" onClick={openImportProject} className={linkBtn} title="Set up a whole series from a session list (.docx)">Import Session List</button>
<button type="button" onClick={openNewProject} className="rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ New Project</button>
</>
@@ -137,6 +146,15 @@ export default function HomePage() {
>
<main className="mx-auto grid max-w-6xl gap-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
<section className="min-w-0">
{projectIndex.length > 0 && (() => {
const age = backupAge(lastBackupAt());
return (
<p className={`mb-3 flex flex-wrap items-center gap-2 px-1 text-xs ${age.stale ? 'text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>
{age.text}.
<button type="button" onClick={openBackup} className="text-[var(--r-accent)] hover:underline">Back up now</button>
</p>
);
})()}
<WeeklyRecall />
<RecallHistory />
<IqDailyCard />
@@ -307,6 +325,7 @@ export default function HomePage() {
</div>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
<OfflineBadge project={studyBooks[entry.id]} offline={offline} />
</div>
<div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
+107
View File
@@ -0,0 +1,107 @@
// Insights: your studies seen together — this week's own work, themes (tags), words you keep studying,
// what slipped in recalls, and follow-ups still open.
import { useMemo } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
import { loadProjectById } from '../utils/projectStorage.js';
import { followUpList, slippedList, themeIndex, weeklySummary, wordIndex } from '../utils/insights.js';
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5';
const open = 'text-xs text-[var(--r-accent)] hover:underline';
function Stat({ label, value }) {
return (
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-4 py-3">
<p className="text-2xl font-medium tabular-nums">{value}</p>
<p className="text-xs text-[var(--r-sub)]">{label}</p>
</div>
);
}
export default function InsightsPage() {
const { projectIndex, openStudyChunk, goHome } = useApp();
const data = useMemo(() => {
const projects = projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean);
return { week: weeklySummary(projects), themes: themeIndex(projects), words: wordIndex(projects), slipped: slippedList(projects), followUps: followUpList(projects) };
}, [projectIndex]);
const { week, themes, words, slipped, followUps } = data;
const link = (item) => <button type="button" onClick={() => openStudyChunk(item.projectId, item.chunkId)} className={open}>{item.reference} · {item.projectTitle}</button>;
return (
<PageShell title="Insights" active="studies" onBack={goHome} maxWidth="max-w-4xl">
<main className="mx-auto max-w-4xl space-y-5 px-4 pb-10">
<section aria-label="This week" className={card}>
<h2 className="text-sm font-medium">This week</h2>
<p className="mb-3 mt-0.5 text-xs text-[var(--r-sub)]">Counted over the chunks you edited in the last 7 days. Pasted text isn’t counted as yours.</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Chunks worked on" value={week.chunksTouched} />
<Stat label="Words of your own" value={week.ownWords} />
<Stat label="Your own words" value={week.ownShare === null ? '–' : `${week.ownShare}%`} />
<Stat label="Recalls done" value={week.recalls} />
</div>
{week.teachBacks > 0 && <p className="mt-2 text-xs text-[var(--r-sub)]">{week.teachBacks} teach-it-back summar{week.teachBacks === 1 ? 'y' : 'ies'} written.</p>}
</section>
<section aria-label="Open follow-ups" className={card}>
<h2 className="text-sm font-medium">Follow-ups</h2>
{followUps.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">None yet. Add them under “More” in the Study stage.</p> : (
<ul className="mt-2 space-y-1.5 text-sm">
{followUps.map((f) => (
<li key={f.id} className="flex flex-wrap items-baseline gap-x-3">
<span className={f.done ? 'text-[var(--r-sub)] line-through' : ''}>{f.text}</span>
{link(f)}
</li>
))}
</ul>
)}
</section>
<section aria-label="What slipped" className={card}>
<h2 className="text-sm font-medium">What slipped in recalls</h2>
{slipped.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">Nothing yet. After a weekly recall, note what you missed.</p> : (
<ul className="mt-2 space-y-3 text-sm">
{slipped.map((r) => (
<li key={`${r.chunkId}-${r.at}`}>
<p className="text-xs text-[var(--r-sub)]">{new Date(r.at).toLocaleDateString()} · {link(r)}</p>
<ul className="ml-4 list-disc">{r.items.map((i) => <li key={i}>{i}</li>)}</ul>
</li>
))}
</ul>
)}
</section>
<section aria-label="Themes" className={card}>
<h2 className="text-sm font-medium">Themes</h2>
{themes.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">Tag a chunk (under “More”) and it appears here with every other chunk on the same theme.</p> : (
<div className="mt-2 space-y-3">
{themes.map((t) => (
<details key={t.tag} className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2">
<summary className="cursor-pointer text-sm"><span className="font-medium">{t.tag}</span> <span className="text-xs text-[var(--r-sub)]">· {t.count} chunk{t.count === 1 ? '' : 's'}</span></summary>
<ul className="mt-2 space-y-2">
{t.items.map((i) => (
<li key={`${i.chunkId}`} className="text-sm">{link(i)}{i.snippet && <p className="font-serif text-[13px] text-[var(--r-sub)]">{i.snippet}</p>}</li>
))}
</ul>
</details>
))}
</div>
)}
</section>
<section aria-label="Words you keep studying" className={card}>
<h2 className="text-sm font-medium">Words you keep studying</h2>
{words.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">Words you study in two or more places show up here.</p> : (
<ul className="mt-2 space-y-2 text-sm">
{words.map((w) => (
<li key={w.key}>
<span className="font-serif text-base">{w.lexeme}</span> <span className="text-xs text-[var(--r-sub)]">{[w.transliteration, w.strong, w.gloss].filter(Boolean).join(' · ')} · {w.count} places</span>
<span className="mt-0.5 flex flex-wrap gap-x-3">{w.places.map((p) => <span key={p.chunkId}>{link(p)}</span>)}</span>
</li>
))}
</ul>
)}
</section>
</main>
</PageShell>
);
}
+14 -2
View File
@@ -11,12 +11,13 @@ 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 { firstOverPoint, outlineSteps, planStatus } from '../utils/present.js';
import IqOutlineAttach from '../components/iq/IqOutlineAttach.jsx';
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
import {
KINDS, STUDY, buildChunkFromNotes, buildFromNotes, notesBuildCount, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, totalMinutes, updateNode, withOutline,
} from '../utils/outline.js';
const iconBtn = 'flex h-7 w-7 items-center justify-center rounded-md text-sm text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] disabled:opacity-30 disabled:hover:bg-transparent';
@@ -45,6 +46,11 @@ export default function OutlinePage() {
const scopeChunk = scope === STUDY ? null : chunkOf(project, scope);
const tree = outlineFor(project, scope);
const rows = flatten(tree);
// The time you have for a session, and how the planned minutes measure up to it
const sessionMinutes = Number(project?.sessionMinutes) || 0;
const plan = planStatus(totalMinutes(tree), sessionMinutes);
const overPoint = firstOverPoint(outlineSteps(tree), sessionMinutes);
const setSessionMinutes = (value) => updateProject((current) => ({ ...current, sessionMinutes: value === '' ? null : Math.max(0, Math.min(600, Math.round(Number(value)))) }));
const [pendingFocus, setPendingFocus] = useState(null);
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
const [shareOpen, setShareOpen] = useState(false);
@@ -160,6 +166,11 @@ export default function OutlinePage() {
</select>
</label>
{timeSummary(tree) && <span className="text-sm text-[var(--r-sub)]">{timeSummary(tree)}</span>}
<label className="flex items-center gap-1.5 text-sm text-[var(--r-sub)]">Session length
<input type="number" min="0" max="600" inputMode="numeric" aria-label="Session length in minutes" value={project?.sessionMinutes ?? ''} onChange={(e) => setSessionMinutes(e.target.value)}
className="w-16 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)] focus:outline-none" /> min
</label>
{plan.state !== 'unset' && <span role="status" className={`text-sm ${plan.state === 'over' ? 'font-medium text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>{plan.text}</span>}
{scopeChunk && <button type="button" onClick={() => openChunk(scope)} className="text-sm text-[var(--r-accent)] hover:underline">Open this chunk in Study</button>}
</div>
@@ -229,10 +240,11 @@ export default function OutlinePage() {
/>
{/* What the point carries, at a glance */}
{(node.kind || mins || node.ref || linked) && (
{(node.kind || mins || node.ref || linked || overPoint?.id === node.id) && (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 text-xs text-[var(--r-sub)]">
{node.kind && <span className={`${chip} border-[var(--r-accent)] text-[var(--r-accent)]`}>{kindLabel(node.kind)}</span>}
{mins && <span>{node.minutes} min</span>}
{overPoint?.id === node.id && <span className="font-medium text-[var(--r-danger,#b42318)]">Runs past {sessionMinutes} min here</span>}
{node.ref && <span>{node.ref}</span>}
{linked && node.chunkId !== scope && <button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button>}
</p>
+1 -1
View File
@@ -1,5 +1,5 @@
// Every Greek and Hebrew word in the study with its transliteration, so a
// speaker can rehearse them. Reached from the Script and Export stages.
// speaker can rehearse them. Reached from the Script and Finish stages.
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import StudioShell from '../components/study/StudioShell.jsx';
+54 -7
View File
@@ -13,10 +13,15 @@ import OutlineRail from '../components/study/OutlineRail.jsx';
import ScripturePane from '../components/study/ScripturePane.jsx';
import NotesPane from '../components/study/NotesPane.jsx';
import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.jsx';
import CompareSheet from '../components/study/CompareSheet.jsx';
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
// Keyboard-only study: 1 / 2 / 3 jump to Observe / Interpret / Apply; c, w, x, p open the resource tabs; v compares.
const NOTE_KEYS = { 1: 'observation', 2: 'interpretation', 3: 'application' };
const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people' };
export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate } = useApp();
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
// Short-lived status line (word suggestions, lookups, …)
const [statusMessage, setStatusMessage] = useState('');
@@ -41,6 +46,26 @@ export default function StudyPage() {
const [tab, setTab] = useResourceTab();
const [openWordId, setOpenWordId] = useState(null);
const [comparing, setComparing] = useState(false);
// Either half of the right column can be folded away so the other fills it (remembered on this device).
const [folded, setFolded] = useState(() => {
try { const f = JSON.parse(localStorage.getItem('study-folded')); return { notes: !!f?.notes, resources: !!f?.resources && !f?.notes }; } catch { return { notes: false, resources: false }; }
});
const [focusRequest, setFocusRequest] = useState(null);
const showHalf = (which) => setFolded((cur) => {
if (!cur[which]) return cur;
const next = { ...cur, [which]: false };
try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ }
return next;
});
const toggleFold = (which) => setFolded((cur) => {
const other = which === 'notes' ? 'resources' : 'notes';
// folding the last open half opens the other one instead, so the column is never empty
const next = { ...cur, [which]: !cur[which] };
if (next[which] && next[other]) next[other] = false;
try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ }
return next;
});
useEffect(() => { setOpenWordId(null); }, [selectedChunk?.id]);
const resources = useStudyResources({
@@ -62,11 +87,15 @@ export default function StudyPage() {
const editable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable;
if (event.key === 'Escape') {
if (wordStudy.suggestModal) { wordStudy.setSuggestModal(null); wordStudy.setSuggestSelection(new Set()); }
else if (editable && !comparing) event.target.blur(); // leave a note box and go back to keyboard moves
return;
}
if (editable || wordStudy.suggestModal) return;
if (editable || wordStudy.suggestModal || comparing || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === 'ArrowLeft') { event.preventDefault(); goPrev(); }
else if (event.key === 'ArrowRight') { event.preventDefault(); goNext(); }
else if (NOTE_KEYS[event.key]) { event.preventDefault(); showHalf('notes'); setFocusRequest({ field: NOTE_KEYS[event.key], n: Date.now() }); }
else if (TAB_KEYS[event.key.toLowerCase()]) { event.preventDefault(); showHalf('resources'); setTab(TAB_KEYS[event.key.toLowerCase()]); }
else if (event.key.toLowerCase() === 'v') { event.preventDefault(); setComparing(true); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
@@ -103,6 +132,7 @@ export default function StudyPage() {
)}
</div>
<div className="flex shrink-0 gap-1.5">
<button type="button" onClick={() => setComparing(true)} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5" title="Set another passage beside this one">Compare…</button>
<button type="button" onClick={goPrev} disabled={globalIndex <= 0} aria-label="Previous chunk"
className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-40">‹</button>
<button type="button" onClick={goNext} disabled={globalIndex >= allChunks.length - 1} aria-label="Next chunk"
@@ -114,6 +144,8 @@ export default function StudyPage() {
verses={verses} chapter={chapter}
interlinear={resources.interlinearData} interlinearLoading={resources.interlinearLoading}
addedStrongs={addedStrongs} onPickWord={pickWord}
verseNotes={selectedChunk.verseNotes ?? {}}
onVerseNote={(key, text) => updateChunk(selectedChunk.id, { verseNotes: { ...(selectedChunk.verseNotes ?? {}), [key]: text } })}
/>
</div>
) : (
@@ -129,25 +161,40 @@ export default function StudyPage() {
</div>
{selectedChunk && chapter && (
<>
<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?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} />
<section aria-label="Notes" className={`flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] ${folded.notes ? 'shrink-0' : 'min-h-[22rem] lg:min-h-0 lg:flex-[11]'}`}>
<h2 className="shrink-0">
<button type="button" onClick={() => toggleFold('notes')} aria-expanded={!folded.notes}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5">
Your notes<span aria-hidden="true">{folded.notes ? '▸' : '▾'}</span>
</button>
</h2>
<div className={`min-h-0 flex-1 overflow-y-auto border-t border-[var(--r-line)] p-3 ${folded.notes ? 'hidden' : ''}`}>
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} focusRequest={focusRequest}
onPray={() => startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
</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]">
<section aria-label="Resources" className={`flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] ${folded.resources ? 'shrink-0' : 'min-h-[24rem] lg:min-h-0 lg:flex-[10]'}`}>
<h2 className="shrink-0">
<button type="button" onClick={() => toggleFold('resources')} aria-expanded={!folded.resources}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5">
Commentary &amp; resources<span aria-hidden="true">{folded.resources ? '▸' : '▾'}</span>
</button>
</h2>
<div className={`flex min-h-0 flex-1 flex-col border-t border-[var(--r-line)] ${folded.resources ? 'hidden' : ''}`}>
<ResourcePane
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
updateChunk={updateChunk} notify={notify}
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
openWordId={openWordId} setOpenWordId={setOpenWordId}
/>
</div>
</section>
</>
)}
</aside>
</div>
{comparing && selectedChunk && <CompareSheet currentChunkId={selectedChunk.id} onClose={() => setComparing(false)} />}
<SuggestWordsSheet wordStudy={wordStudy} />
{statusMessage && (
<div role="status" className="fixed bottom-6 left-1/2 z-[60] -translate-x-1/2 rounded-full bg-[var(--r-text)] px-4 py-2 text-sm text-[var(--r-bg)] shadow-lg">
+90 -7
View File
@@ -190,16 +190,16 @@ describe('Home page', () => {
});
// ---------------------------------------------------------------------------
// Export stage (useShareLink) and the shared read-only view
// Finish stage (useShareLink) and the shared read-only view
// ---------------------------------------------------------------------------
describe('Project header and sharing', () => {
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
test('Export stage → Markdown downloads a file', async () => {
test('Finish stage → Markdown downloads a file', async () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^export$/i }));
await user.click(screen.getByRole('button', { name: /^finish$/i }));
await user.click(await screen.findByRole('button', { name: /export markdown/i }));
expect(URL.createObjectURL).toHaveBeenCalled();
});
@@ -216,7 +216,7 @@ describe('Project header and sharing', () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^export$/i }));
await user.click(screen.getByRole('button', { name: /^finish$/i }));
await user.click(await screen.findByRole('button', { name: /enable sharing/i }));
expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument();
});
@@ -310,12 +310,12 @@ describe('Chunk builder', () => {
});
// ---------------------------------------------------------------------------
// Stage bar (Chunks / Study / Outline / Script / Export)
// Stage bar (Chunks / Study / Outline / Script / Finish)
// ---------------------------------------------------------------------------
describe('Stage bar', () => {
test('later stages are disabled until a chunk exists', async () => {
await loadChapter();
for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^export$/i]) {
for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^finish$/i]) {
expect(screen.getByRole('button', { name })).toBeDisabled();
}
expect(screen.getByRole('button', { name: /^chunks$/i })).not.toBeDisabled();
@@ -419,12 +419,96 @@ describe('Study workspace', () => {
test('commentary: "Add to notes" quotes a paragraph with its citation', async () => {
const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
expect(screen.getByText('Observe first')).toBeInTheDocument(); // commentary waits for your own notes
await user.click(screen.getByRole('button', { name: /show it anyway/i }));
await user.click(await screen.findByRole('button', { name: /add to notes/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i);
await user.click(screen.getByRole('button', { name: /^more/i }));
expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)');
});
test('commentary opens once you have written enough of your own Observe notes', async () => {
await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
expect(screen.getByText('Observe first')).toBeInTheDocument();
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: Array.from({ length: 15 }, (_, i) => `word${i}`).join(' ') } });
expect(screen.queryByText('Observe first')).not.toBeInTheDocument();
expect(await screen.findByRole('button', { name: /add to notes/i })).toBeInTheDocument();
});
test('either half of the right column can be folded, and the other always stays open', async () => {
const user = await studyOneChunk();
const notes = screen.getByRole('button', { name: /your notes/i });
const resources = screen.getByRole('button', { name: /commentary & resources/i });
expect(notes).toHaveAttribute('aria-expanded', 'true');
await user.click(notes);
expect(notes).toHaveAttribute('aria-expanded', 'false');
expect(resources).toHaveAttribute('aria-expanded', 'true');
await user.click(resources); // folding the last open half opens the other one
expect(resources).toHaveAttribute('aria-expanded', 'false');
expect(notes).toHaveAttribute('aria-expanded', 'true');
});
test('a long paste asks if it is from AI; "I wrote it" takes the tag back', async () => {
const user = await studyOneChunk();
const observe = screen.getByRole('textbox', { name: 'Observe' });
const pasted = Array.from({ length: 20 }, (_, i) => `pasted${i}`).join(' ');
fireEvent.paste(observe, { clipboardData: { getData: () => pasted } });
fireEvent.change(observe, { target: { value: pasted } });
expect(await screen.findByText(/is this from ai\?/i)).toBeInTheDocument();
expect(screen.getByText(/20 words here were pasted/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /i wrote it/i }));
expect(screen.queryByText(/is this from ai\?/i)).not.toBeInTheDocument();
expect(screen.queryByText(/were pasted/i)).not.toBeInTheDocument();
});
test('a short paste is not asked about', async () => {
await studyOneChunk();
const observe = screen.getByRole('textbox', { name: 'Observe' });
fireEvent.paste(observe, { clipboardData: { getData: () => 'just a few words' } });
expect(screen.queryByText(/is this from ai\?/i)).not.toBeInTheDocument();
});
test('a note keeps earlier versions that can be restored', async () => {
const user = await studyOneChunk();
const observe = screen.getByRole('textbox', { name: 'Observe' });
fireEvent.focus(observe);
fireEvent.change(observe, { target: { value: 'First thought about the passage.' } });
fireEvent.blur(observe);
fireEvent.focus(observe);
fireEvent.change(observe, { target: { value: 'Completely rewritten.' } });
fireEvent.blur(observe);
await user.click(screen.getByRole('button', { name: 'History' }));
const list = screen.getByRole('list', { name: /observe history/i });
expect(within(list).getByText(/first thought about the passage/i)).toBeInTheDocument();
await user.click(within(list).getAllByRole('button', { name: 'Restore' }).find((b) => !b.disabled));
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('First thought about the passage.');
});
test('keyboard: 1 / 2 / 3 jump into a note, Esc leaves it, and V opens Compare', async () => {
const user = await studyOneChunk();
await user.keyboard('2');
expect(screen.getByRole('textbox', { name: 'Interpret' })).toHaveFocus();
await user.keyboard('{Escape}');
expect(screen.getByRole('textbox', { name: 'Interpret' })).not.toHaveFocus();
await user.keyboard('3');
expect(screen.getByRole('textbox', { name: 'Apply' })).toHaveFocus();
await user.keyboard('{Escape}');
await user.keyboard('v');
expect(screen.getByRole('dialog', { name: /compare/i })).toBeInTheDocument();
});
test('compare translations: the Observe / Interpret / Apply boxes edit the same notes', async () => {
const user = await studyOneChunk();
await user.click(screen.getByRole('button', { name: /compare…/i }));
await user.click(screen.getByRole('tab', { name: /translations/i }));
const dialog = screen.getByRole('dialog', { name: /compare/i });
expect(within(dialog).getByRole('textbox', { name: 'Interpret' })).toBeInTheDocument();
expect(within(dialog).getByRole('textbox', { name: 'Apply' })).toBeInTheDocument();
fireEvent.change(within(dialog).getByRole('textbox', { name: 'Observe' }), { target: { value: 'Written beside the translations.' } });
await user.click(within(dialog).getByRole('button', { name: /close/i }));
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Written beside the translations.');
});
test('people & places: lists what is tagged in the chunk', async () => {
const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
await user.click(screen.getByRole('tab', { name: /people & places/i }));
@@ -514,4 +598,3 @@ describe('Reading plan with more days than chapters', () => {
expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument();
});
});
+1 -1
View File
@@ -26,7 +26,7 @@ describe('Back to the dashboard', () => {
await dashboard();
});
test.each(['Chunks', 'Study', 'Outline', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
test.each(['Chunks', 'Study', 'Outline', 'Script', 'Finish'])('the %s stage of a study', async (stage) => {
const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
+11
View File
@@ -89,3 +89,14 @@ export function mergeReadingPlans(local, incoming) {
activity: [...new Set([...(local.activity ?? []), ...(incoming.activity ?? [])])].sort(),
};
}
// When you last downloaded a backup on this device, so Home can say so (and nudge when it has been a while).
const LAST_BACKUP_KEY = 'last-backup-at';
export const markBackedUp = (now = Date.now()) => { try { localStorage.setItem(LAST_BACKUP_KEY, String(now)); } catch { /* storage unavailable */ } };
export const lastBackupAt = () => { try { return Number(localStorage.getItem(LAST_BACKUP_KEY)) || null; } catch { return null; } };
export function backupAge(at, now = Date.now()) {
if (!at) return { text: 'You haven’t downloaded a backup yet', stale: true };
const days = Math.floor((now - at) / 86400000);
if (days < 1) return { text: 'Backed up today', stale: false };
return { text: `Last backup ${days} day${days === 1 ? '' : 's'} ago`, stale: days >= 14 };
}
+10
View File
@@ -68,3 +68,13 @@ test('prayers are in the backup, deleted ones are not, and they survive validati
expect(checked.backup.prayers[0].text).toBe('Peace');
expect(validateBackup({ app: 'bible-study-project', version: 1, prayers: [{ id: 'x' }, 5, { id: 'y', text: ' ' }] }).backup.prayers).toEqual([]);
});
import { backupAge } from './backup.js';
test('the backup age says how long ago, and flags a long gap', () => {
const now = 1_000_000_000_000;
expect(backupAge(null, now)).toMatchObject({ stale: true });
expect(backupAge(now - 3600_000, now)).toEqual({ text: 'Backed up today', stale: false });
expect(backupAge(now - 5 * 86400000, now)).toEqual({ text: 'Last backup 5 days ago', stale: false });
expect(backupAge(now - 20 * 86400000, now).stale).toBe(true);
});
+56
View File
@@ -0,0 +1,56 @@
// Comparing translations of one chunk: which chapter(s) and verses to fetch, and the verses lined up by number.
import { getChunkVerseEntries } from './bibleUtils.jsx';
import { diffVerses } from './textDiff.js';
// -> [{ bookAbbrev, book, chapter, from, to }]: one entry per chapter the chunk covers (two when it spills over).
export function translationSpans(project, chapterIndex, chunk) {
const spans = [];
for (const v of getChunkVerseEntries(project, chapterIndex, chunk)) {
const last = spans[spans.length - 1];
if (last && last.chapter === v.chapter && last.book === v.book) { last.to = Math.max(last.to, v.number); last.from = Math.min(last.from, v.number); continue; }
const chapter = project.chapters.find((c) => c.book === v.book && String(c.chapter) === String(v.chapter));
spans.push({ bookAbbrev: chapter?.bookAbbrev, book: v.book, chapter: v.chapter, from: v.number, to: v.number });
}
return spans;
}
// byTranslation: { BSB: { verses: [{ number, text }] }, … } for the chapters in `spans`; `loaded` is keyed by
// `${chapter}` inside each translation. -> [{ key, label, texts: { BSB: 'text' | null } }] one row per verse, in order.
export function alignVerses(spans, byTranslation) {
const rows = [];
for (const span of spans) {
for (let n = span.from; n <= span.to; n += 1) {
const texts = {};
for (const [id, chapters] of Object.entries(byTranslation)) {
texts[id] = chapters?.[`${span.book}|${span.chapter}`]?.find((v) => v.number === n)?.text ?? null;
}
rows.push({ key: `${span.book}${span.chapter}:${n}`, noteKey: `${span.chapter}:${n}`, label: spans.length > 1 ? `${span.chapter}:${n}` : String(n), texts });
}
}
return rows;
}
// For one verse in several translations: which words in each text the others lack, measured against the first
// translation (and the first against the second). -> { BSB: [{ start, end }], NET: [...] }
export function differencesFor(texts, ids) {
const out = Object.fromEntries(ids.map((id) => [id, []]));
const present = ids.filter((id) => texts[id]);
if (present.length < 2) return out;
const [base, ...rest] = present;
out[base] = diffVerses(texts[base], texts[rest[0]]).a;
for (const id of rest) out[id] = diffVerses(texts[base], texts[id]).b;
return out;
}
// Cuts a text at the ranges -> [{ text, mark }] so a view can wrap the marked parts.
export function markRanges(text, ranges) {
const parts = [];
let at = 0;
for (const r of [...(ranges ?? [])].sort((a, b) => a.start - b.start)) {
if (r.start > at) parts.push({ text: text.slice(at, r.start), mark: false });
parts.push({ text: text.slice(r.start, r.end), mark: true });
at = r.end;
}
if (at < text.length) parts.push({ text: text.slice(at), mark: false });
return parts;
}
+45
View File
@@ -0,0 +1,45 @@
import { test, expect } from 'vitest';
import { translationSpans, alignVerses } from './compareTranslations.js';
const verses = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => ({ number: from + i, text: `v${from + i}` }));
const project = { chapters: [
{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: verses(1, 36), chunks: [] },
{ book: 'John', bookAbbrev: 'JHN', chapter: '4', verses: verses(1, 54), chunks: [] },
] };
test('a chunk inside one chapter is a single span', () => {
expect(translationSpans(project, 0, { startVerse: 16, endVerse: 18, spilloverEndVerse: null }))
.toEqual([{ bookAbbrev: 'JHN', book: 'John', chapter: '3', from: 16, to: 18 }]);
});
test('a chunk that runs into the next chapter has a span for each', () => {
const spans = translationSpans(project, 0, { startVerse: 35, endVerse: 36, spilloverEndVerse: 2 });
expect(spans.map((s) => [s.chapter, s.from, s.to])).toEqual([['3', 35, 36], ['4', 1, 2]]);
});
test('verses are lined up by number, with null where a translation lacks one', () => {
const spans = [{ book: 'John', chapter: '3', from: 16, to: 17 }];
const rows = alignVerses(spans, {
BSB: { 'John|3': [{ number: 16, text: 'a' }, { number: 17, text: 'b' }] },
NET: { 'John|3': [{ number: 16, text: 'c' }] },
});
expect(rows.map((r) => r.label)).toEqual(['16', '17']);
expect(rows[0].texts).toEqual({ BSB: 'a', NET: 'c' });
expect(rows[1].texts).toEqual({ BSB: 'b', NET: null });
});
import { differencesFor, markRanges } from './compareTranslations.js';
test('differences are the words a translation has that the first one lacks', () => {
const d = differencesFor({ BSB: 'For God so loved the world', NET: 'For this is the way God loved the world', ESV: 'For God so loved the world' }, ['BSB', 'NET', 'ESV']);
expect(d.ESV).toEqual([]); // same words as the first
const text = 'For this is the way God loved the world';
expect(markRanges(text, d.NET).filter((p) => p.mark).map((p) => p.text.trim()).join(' ')).toContain('this is the way');
expect(differencesFor({ BSB: 'a b', NET: null }, ['BSB', 'NET']).BSB).toEqual([]); // nothing to compare against
});
test('markRanges splits a text into plain and marked pieces that rejoin to the original', () => {
const parts = markRanges('one two three four', [{ start: 4, end: 7 }, { start: 14, end: 18 }]);
expect(parts.map((p) => p.text).join('')).toBe('one two three four');
expect(parts.filter((p) => p.mark).map((p) => p.text)).toEqual(['two', 'four']);
});
+16 -7
View File
@@ -30,16 +30,24 @@ export function questionsFor(chunk) {
return fallback.length ? { kind: 'think', items: fallback } : { kind: 'none', items: [] };
}
function sessionHtml(project, session, { answerLines }) {
// layout: 'standard' (everything), 'questions' (passage + questions only, one page), 'large' (big print), 'notes' (passage and a blank page to write).
export const HANDOUT_LAYOUTS = [
{ id: 'standard', label: 'Standard: passage, outline, questions' },
{ id: 'questions', label: 'Questions only: passage and discussion questions' },
{ id: 'large', label: 'Large print: passage and questions' },
{ id: 'notes', label: 'Note-taking: passage and lots of lines' },
];
function sessionHtml(project, session, { answerLines, layout = 'standard' }) {
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 outline = layout === 'standard' ? flatten(outlineForSession(project, chunk)) : [];
const q = layout === 'notes' ? { kind: 'none', items: [] } : questionsFor(chunk);
const blank = (n) => Array.from({ length: n }, () => '<div class="rule"></div>').join('');
return `<section class="page">
return `<section class="page${layout === 'standard' ? '' : ` ${layout}`}">
<header>
<p class="kicker">${esc(series || project.title)}</p>
<h1>Session ${esc(num)}${title ? `: ${esc(title)}` : ''}</h1>
@@ -49,13 +57,13 @@ function sessionHtml(project, session, { answerLines }) {
${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)}` : ''}
${answerLines || layout === 'notes' ? `<h2>Notes</h2>${blank(layout === 'notes' ? 16 : 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 } = {}) {
export function buildHandoutHtml(project, ids = null, { answerLines = true, layout = 'standard' } = {}) {
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; }
@@ -71,8 +79,9 @@ export function buildHandoutHtml(project, ids = null, { answerLines = true } = {
.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; }
.large { font-size: 17pt; line-height: 1.55; } .large h1 { font-size: 30pt; } .large .rule { height: 2.2em; }
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>`;
</style></head><body>${sessions.map((s) => sessionHtml(project, s, { answerLines, layout })).join('')}</body></html>`;
}
export function printHandout(project, ids = null, options) {
+16
View File
@@ -46,3 +46,19 @@ test('one session only, and no writing lines when switched off', () => {
expect(html).toContain('Session 2');
expect(html).not.toContain('class="rule"');
});
import { buildHandoutHtml as buildLayoutHtml } from './handout.js';
test('layouts change what the handout prints', () => {
const project = { id: 'p', title: 'T', translation: 'BSB', outline: [], chapters: [{ book: 'John', bookAbbrev: 'Jhn', chapter: '3', verses: [{ number: 16, text: 'For God so loved.' }], chunks: [{ id: 'c', startVerse: 16, endVerse: 16, discussionQuestions: 'Why did he give?', application: 'Trust him.', outline: [{ id: 'n', text: 'Point one', children: [] }] }] }] };
const standard = buildLayoutHtml(project);
const questions = buildLayoutHtml(project, null, { layout: 'questions' });
const notes = buildLayoutHtml(project, null, { layout: 'notes' });
const large = buildLayoutHtml(project, null, { layout: 'large' });
expect(standard).toContain('Point one');
expect(questions).not.toContain('Point one');
expect(questions).toContain('Why did he give?');
expect(notes).not.toContain('Why did he give?');
expect(notes.match(/class="rule"/g).length).toBeGreaterThanOrEqual(16);
expect(large).toContain('class="page large"');
});
+90
View File
@@ -0,0 +1,90 @@
// Bring notes written elsewhere (a Word file or Markdown from another app) into a study. A passage heading starts
// each section ("## John 3:16–18"), and labelled lines or sub-headings ("Observe: …", "### Interpret") hold the
// notes. Sections are matched to your chunks by passage. Nothing already written is overwritten.
import { parsePassageRef } from './passageRef.js';
import { withPaste } from './ownWork.js';
const LABELS = [
[/^observ(?:e|ation|ations)$/i, 'observation'],
[/^interpret(?:ation)?$/i, 'interpretation'],
[/^appl(?:y|ication)$/i, 'application'],
[/^(?:background|notes?|general notes)$/i, 'generalNotes'],
[/^(?:discussion )?questions$/i, 'discussionQuestions'],
];
const FIELD_NAMES = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply', generalNotes: 'Background', discussionQuestions: 'Questions' };
export const importFieldName = (f) => FIELD_NAMES[f] ?? f;
const clean = (line) => line.replace(/^\s*#+\s*/, '').replace(/\*\*|__/g, '').trim();
const labelOf = (word) => LABELS.find(([re]) => re.test(word.trim()))?.[1] ?? null;
// A heading that is just a passage ("John 3:16-18", "Session 2: Romans 8:1–4") -> { book, chapter, verse, text } or null.
export function passageHeading(line) {
const text = clean(line).replace(/^(?:session|lesson|week|episode|part)\s*\d+\s*[:.\-–—]\s*/i, '');
const m = text.match(/^((?:[1-3]\s*)?[A-Za-z][A-Za-z .]*?)\s+(\d{1,3})(?:\s*:\s*(\d{1,3}))?(?:\s*[-–—]\s*\d{1,3}(?:\s*:\s*\d{1,3})?)?\s*$/);
if (!m || text.length > 60) return null;
const ref = parsePassageRef(`${m[1]} ${m[2]}${m[3] ? `:${m[3]}` : ''}`)[0];
return ref ? { book: ref.book, chapter: ref.chapter, verse: m[3] ? Number(m[3]) : null, text } : null;
}
// -> [{ heading, ref, observation?, interpretation?, application?, generalNotes?, discussionQuestions? }]
export function parseNotesText(raw) {
const sections = [];
let current = null;
let field = null;
const append = (value) => {
if (!current || !field) return;
current[field] = current[field] ? `${current[field]}\n${value}` : value;
};
for (const line of String(raw ?? '').replace(/\r/g, '').split('\n')) {
const heading = passageHeading(line);
if (heading) { current = { heading: heading.text, ref: heading }; sections.push(current); field = null; continue; }
if (!current) continue;
const stripped = clean(line).replace(/^[-*•]\s+/, '');
const labelled = stripped.match(/^([A-Za-z ]{3,22}?)\s*[:\-–]\s*(.*)$/);
const bare = /^\s*(?:#+\s|\*\*|__)/.test(line) ? labelOf(stripped.replace(/[:\-–]\s*$/, '')) : null; // "### Observe", "**Observe**"
if (labelled && labelOf(labelled[1])) { field = labelOf(labelled[1]); current[field] = current[field] ?? ''; if (labelled[2].trim()) append(labelled[2].trim()); continue; }
if (bare) { field = bare; current[field] = current[field] ?? ''; continue; }
if (field) append(line.trim());
}
return sections.map((s) => Object.fromEntries(Object.entries(s).map(([k, v]) => [k, typeof v === 'string' ? v.replace(/^\n+|\n+$/g, '').trim() : v])))
.filter((s) => Object.keys(FIELD_NAMES).some((f) => s[f]));
}
// Which chunk a section belongs to: the one whose verses include the heading's verse (or the first in that chapter).
export function matchChunk(project, ref) {
for (const chapter of project?.chapters ?? []) {
if (chapter.bookAbbrev !== ref.book || Number(chapter.chapter) !== ref.chapter) continue;
const chunks = chapter.chunks ?? [];
const hit = ref.verse == null ? chunks[0] : chunks.find((c) => ref.verse >= c.startVerse && ref.verse <= c.endVerse);
if (hit) return hit;
}
return null;
}
const MARKER = '— imported —';
// -> { project, matched: [{ heading, fields }], unmatched: [heading] }. Where a note already has text, the imported
// text is added below a marker instead of replacing it. `own: false` records the text as pasted, not your own work.
export function applyImportedNotes(project, sections, { own = true, now = Date.now() } = {}) {
const patches = new Map(); // chunk id -> merged chunk
const matched = [];
const unmatched = [];
for (const section of sections) {
const chunk = matchChunk(project, section.ref);
if (!chunk) { unmatched.push(section.heading); continue; }
const next = { ...(patches.get(chunk.id) ?? chunk) };
const fields = [];
for (const f of Object.keys(FIELD_NAMES)) {
const incoming = section[f];
if (!incoming) continue;
const have = (next[f] ?? '').trim();
if (have.includes(incoming)) continue;
next[f] = have ? `${next[f].replace(/\s+$/, '')}\n\n${MARKER}\n${incoming}` : incoming;
if (!own && ['observation', 'interpretation', 'application'].includes(f)) next.aiPastes = withPaste({ aiPastes: next.aiPastes }, f, incoming) ?? { ...(next.aiPastes ?? {}), [f]: [...(next.aiPastes?.[f] ?? []), incoming] };
fields.push(importFieldName(f));
}
if (fields.length) { next.editedAt = now; patches.set(chunk.id, next); matched.push({ heading: section.heading, fields }); }
}
const out = { ...project, chapters: project.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => patches.get(c.id) ?? c) })) };
return { project: out, matched, unmatched };
}
+60
View File
@@ -0,0 +1,60 @@
import { test, expect } from 'vitest';
import { parseNotesText, passageHeading, applyImportedNotes, matchChunk } from './importNotes.js';
import { ownWords } from './ownWork.js';
const SAMPLE = `# My John study
## John 3:16-18
Observe: God loved the world.
He gave his Son.
Interpret: Love is shown by giving.
### Apply
Trust him this week.
## Session 2: John 3:19–21
**Observation:** Light and darkness.
Questions:
- Why do people love darkness?
`;
test('headings that are a passage are recognised, others are not', () => {
expect(passageHeading('## John 3:16-18')).toMatchObject({ book: 'JHN', chapter: 3, verse: 16 });
expect(passageHeading('Session 2: John 3:19–21')).toMatchObject({ chapter: 3, verse: 19 });
expect(passageHeading('Romans 8')).toMatchObject({ book: 'ROM', chapter: 8, verse: null });
expect(passageHeading('# My John study')).toBeNull();
expect(passageHeading('Observe: God loved')).toBeNull();
});
test('sections and their labelled notes are read from Markdown', () => {
const sections = parseNotesText(SAMPLE);
expect(sections).toHaveLength(2);
expect(sections[0]).toMatchObject({ observation: 'God loved the world.\nHe gave his Son.', interpretation: 'Love is shown by giving.', application: 'Trust him this week.' });
expect(sections[1].observation).toBe('Light and darkness.');
expect(sections[1].discussionQuestions).toContain('Why do people love darkness?');
});
const project = () => ({ id: 'p', title: 'T', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks: [
{ id: 'a', startVerse: 16, endVerse: 18, observation: '', interpretation: 'Mine already.' },
{ id: 'b', startVerse: 19, endVerse: 21, observation: '' },
] }] });
test('notes land in the chunk that holds the verse, and nothing written is overwritten', () => {
const sections = [...parseNotesText(SAMPLE), { heading: 'Romans 8', ref: { book: 'ROM', chapter: 8, verse: null }, observation: 'x' }];
const { project: out, matched, unmatched } = applyImportedNotes(project(), sections, { now: 5 });
const a = out.chapters[0].chunks[0];
expect(a.observation).toContain('God loved the world.');
expect(a.interpretation).toBe('Mine already.\n\n— imported —\nLove is shown by giving.');
expect(out.chapters[0].chunks[1].observation).toBe('Light and darkness.');
expect(matched).toHaveLength(2);
expect(unmatched).toEqual(['Romans 8']);
expect(matchChunk(project(), { book: 'JHN', chapter: 3, verse: 20 }).id).toBe('b');
});
test('importing as "not mine" records the text as pasted, so it is not counted as your own work', () => {
const long = Array.from({ length: 30 }, (_, i) => `w${i}`).join(' ');
const sections = [{ heading: 'John 3:16', ref: { book: 'JHN', chapter: 3, verse: 16 }, observation: long }];
const mine = applyImportedNotes(project(), sections, { own: true }).project.chapters[0].chunks[0];
const notMine = applyImportedNotes(project(), sections, { own: false }).project.chapters[0].chunks[0];
expect(ownWords(mine, 'observation')).toBe(30);
expect(ownWords(notMine, 'observation')).toBe(0);
});
+78
View File
@@ -0,0 +1,78 @@
// Looks across every study at once: this week's own work, tags as themes, the original-language words you
// keep returning to, what slipped in recalls, and follow-ups still open. `projects` are full study projects.
import { formatChunkReference } from './bibleUtils.jsx';
import { NOTE_FIELDS, ownShare, ownWords } from './ownWork.js';
import { WEEK_MS } from './recall.js';
const lines = (text) => String(text ?? '').split('\n').map((l) => l.replace(/^\s*(?:[-*•–]|\d{1,2}[.)])\s+/, '').trim()).filter(Boolean);
// Every chunk with where it lives.
function everyChunk(projects) {
return (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).map((chunk) => ({
project, chunk, projectId: project.id, projectTitle: project.title, chunkId: chunk.id,
reference: formatChunkReference(project, chapterIndex, chunk, '–'),
}))));
}
// Chunks edited in the last 7 days: how many, your own words in them, the share that is yours, and recalls done.
export function weeklySummary(projects, now = Date.now()) {
const all = everyChunk(projects);
const recent = all.filter(({ chunk }) => now - (chunk.editedAt ?? 0) < WEEK_MS && (chunk.editedAt ?? 0) > 0);
const ownWordsTotal = recent.reduce((n, { chunk }) => n + NOTE_FIELDS.reduce((m, f) => m + ownWords(chunk, f), 0), 0);
const shares = recent.map(({ chunk }) => ownShare(chunk)).filter((s) => s !== null);
return {
chunksTouched: recent.length,
ownWords: ownWordsTotal,
ownShare: shares.length ? Math.round(shares.reduce((a, b) => a + b, 0) / shares.length) : null,
recalls: all.reduce((n, { chunk }) => n + (chunk.recalls ?? []).filter((r) => now - (r.at ?? 0) < WEEK_MS).length, 0),
teachBacks: recent.filter(({ chunk }) => (chunk.teachBack ?? '').trim()).length,
};
}
const snippet = (chunk) => {
const raw = (chunk.observation || chunk.interpretation || chunk.application || '').replace(/\s+/g, ' ').trim();
return raw.length > 110 ? `${raw.slice(0, 110)}…` : raw;
};
// Tags across studies: [{ tag, count, items: [{ projectId, projectTitle, chunkId, reference, snippet }] }], most used first.
export function themeIndex(projects) {
const map = new Map();
for (const entry of everyChunk(projects)) {
for (const tag of entry.chunk.tags ?? []) {
const key = tag.trim().toLowerCase();
if (!key) continue;
if (!map.has(key)) map.set(key, { tag: tag.trim(), items: [] });
map.get(key).items.push({ projectId: entry.projectId, projectTitle: entry.projectTitle, chunkId: entry.chunkId, reference: entry.reference, snippet: snippet(entry.chunk) });
}
}
return [...map.values()].map((t) => ({ ...t, count: t.items.length })).sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
}
// Original-language words studied in more than one place (or all, with minCount 1): most often first.
export function wordIndex(projects, { minCount = 2 } = {}) {
const map = new Map();
for (const entry of everyChunk(projects)) {
for (const w of entry.chunk.greekWords ?? []) {
const strong = (w.strongNumber ?? '').toUpperCase();
const key = strong || (w.lexeme ?? w.query ?? '').toLowerCase();
if (!key) continue;
if (!map.has(key)) map.set(key, { key, strong, lexeme: w.lexeme || w.query || key, gloss: w.englishGloss || w.shortDefinition || '', transliteration: w.transliteration ?? '', places: [] });
map.get(key).places.push({ projectId: entry.projectId, projectTitle: entry.projectTitle, chunkId: entry.chunkId, reference: entry.reference });
}
}
return [...map.values()].map((w) => ({ ...w, count: w.places.length })).filter((w) => w.count >= minCount).sort((a, b) => b.count - a.count || a.lexeme.localeCompare(b.lexeme));
}
// What you said slipped in your recalls, newest first: [{ at, reference, projectId, chunkId, items: [line] }].
export function slippedList(projects) {
return everyChunk(projects).flatMap((e) => (e.chunk.recalls ?? [])
.map((r) => ({ at: r.at ?? 0, reference: e.reference, projectId: e.projectId, projectTitle: e.projectTitle, chunkId: e.chunkId, items: lines(r.gaps) }))
.filter((r) => r.items.length)).sort((a, b) => b.at - a.at);
}
// Follow-ups across studies: open ones first (then done), newest chunk first.
export function followUpList(projects) {
return everyChunk(projects).flatMap((e) => (e.chunk.followUps ?? []).map((f) => ({
id: f.id, text: f.text, done: !!f.done, reference: e.reference, projectId: e.projectId, projectTitle: e.projectTitle, chunkId: e.chunkId, editedAt: e.chunk.editedAt ?? 0,
}))).sort((a, b) => Number(a.done) - Number(b.done) || b.editedAt - a.editedAt);
}
+48
View File
@@ -0,0 +1,48 @@
import { test, expect } from 'vitest';
import { weeklySummary, themeIndex, wordIndex, slippedList, followUpList } from './insights.js';
import { WEEK_MS } from './recall.js';
const NOW = 100 * WEEK_MS;
const text = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
const chunk = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 5, observation: text(20), editedAt: NOW - 1000, ...extra });
const study = (chunks, id = 'p1', title = 'Romans') => ({ id, title, translation: 'BSB', chapters: [{ book: 'Romans', bookAbbrev: 'Rom', chapter: 8, chunks }] });
test('the weekly summary counts only chunks edited this week', () => {
const pasted = text(10);
const projects = [study([
chunk('a', { teachBack: 'x', recalls: [{ at: NOW - 1000, rating: 'solid' }] }),
chunk('b', { observation: `${text(10)} ${pasted}`, aiPastes: { observation: [pasted] }, editedAt: NOW - 500 }),
chunk('old', { editedAt: NOW - 3 * WEEK_MS, recalls: [{ at: NOW - 3 * WEEK_MS, rating: 'solid' }] }),
])];
const s = weeklySummary(projects, NOW);
expect(s).toMatchObject({ chunksTouched: 2, ownWords: 30, recalls: 1, teachBacks: 1 });
expect(s.ownShare).toBe(75); // 100% and 50%, averaged
expect(weeklySummary([], NOW).ownShare).toBeNull();
});
test('tags group notes from different studies into themes, most used first', () => {
const p1 = study([chunk('a', { tags: ['Grace'] }), chunk('b', { tags: ['hope'] })]);
const p2 = study([chunk('c', { tags: ['grace '] })], 'p2', 'John');
const themes = themeIndex([p1, p2]);
expect(themes.map((t) => [t.tag, t.count])).toEqual([['Grace', 2], ['hope', 1]]);
expect(themes[0].items.map((i) => i.projectTitle)).toEqual(['Romans', 'John']);
});
test('words that come up in more than one place are listed', () => {
const w = (n, g) => ({ id: n, strongNumber: n, lexeme: g, englishGloss: g });
const projects = [study([chunk('a', { greekWords: [w('G26', 'agape'), w('G4102', 'pistis')] }), chunk('b', { greekWords: [w('g26', 'agape')] })])];
const list = wordIndex(projects);
expect(list).toHaveLength(1);
expect(list[0]).toMatchObject({ strong: 'G26', count: 2 });
expect(wordIndex(projects, { minCount: 1 })).toHaveLength(2);
});
test('slipped lines and follow-ups are collected, open follow-ups first', () => {
const projects = [study([chunk('a', {
recalls: [{ at: 5, gaps: '- the serpent\nwho spoke', rating: 'partly' }, { at: 9, gaps: '', rating: 'solid' }],
followUps: [{ id: '1', text: 'done thing', done: true }, { id: '2', text: 'call Sam', done: false }],
})])];
expect(slippedList(projects)).toHaveLength(1);
expect(slippedList(projects)[0].items).toEqual(['the serpent', 'who spoke']);
expect(followUpList(projects).map((f) => f.text)).toEqual(['call Sam', 'done thing']);
});
+12 -1
View File
@@ -2,7 +2,7 @@
// conflict doesn't force a choice between them. Chunks are matched by id; each chunk carries the time it
// was last edited here (editedAt). Nothing the person wrote is dropped: where both copies changed the
// same note, the newer one stays and the older one is appended under a marker.
const TEXT_FIELDS = ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript', 'episodeNumber', 'episodeTitle'];
const TEXT_FIELDS = ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript', 'episodeNumber', 'episodeTitle', 'teachBack', 'attendees', 'groupNotes'];
const LIST_FIELDS = ['tags', 'crossReferences', 'greekWords'];
export const MERGE_MARKER = '— from your other device —';
@@ -22,6 +22,12 @@ function union(a = [], b = []) {
return [...a, ...b].filter((item) => { const k = JSON.stringify(item); if (seen.has(k)) return false; seen.add(k); return true; });
}
// Follow-ups are matched by id; the newer copy's wording and done-state win, nothing is dropped.
function mergeById(newer = [], older = []) {
const seen = new Set(newer.map((i) => i.id));
return [...newer, ...older.filter((i) => !seen.has(i.id))];
}
export function mergeChunk(mine, theirs) {
if (same(mine, theirs)) return { chunk: mine, changed: false };
const mineNewer = (mine.editedAt ?? 0) >= (theirs.editedAt ?? 0);
@@ -31,6 +37,11 @@ export function mergeChunk(mine, theirs) {
LIST_FIELDS.forEach((f) => { out[f] = union(newer[f], older[f]); });
// Pastes follow their text: keep the union per field so merged notes still show what was pasted.
out.aiPastes = Object.fromEntries(['observation', 'interpretation', 'application'].map((f) => [f, union(newer.aiPastes?.[f], older.aiPastes?.[f])]));
// Verse notes are kept per verse (the newer wording wins, an older different one is appended); the earliest first draft is kept.
const noteKeys = new Set([...Object.keys(newer.verseNotes ?? {}), ...Object.keys(older.verseNotes ?? {})]);
out.verseNotes = Object.fromEntries([...noteKeys].map((k) => [k, mergeText(newer.verseNotes?.[k], older.verseNotes?.[k])]));
out.firstDraft = [newer.firstDraft, older.firstDraft].filter(Boolean).sort((a, b) => a.at - b.at)[0];
out.followUps = mergeById(newer.followUps, older.followUps);
out.recalls = union(newer.recalls, older.recalls);
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
return { chunk: out, changed: !same(out, mine) };
+23
View File
@@ -0,0 +1,23 @@
// Earlier versions of a single note (Observe, Interpret, Apply…), kept on this device so one note can be rolled
// back without restoring the whole study. A version is saved when you start and when you stop editing a note, so
// whatever was there before a big rewrite is always a click away.
export const MAX_VERSIONS = 12;
const keyFor = (chunkId, field) => `note-history:${chunkId}:${field}`;
// -> [{ at, text }], newest first
export function listNoteVersions(chunkId, field) {
try {
const raw = JSON.parse(localStorage.getItem(keyFor(chunkId, field)) || '[]');
return Array.isArray(raw) ? raw.filter((v) => v && typeof v.text === 'string') : [];
} catch { return []; }
}
// Keeps `text` as the newest version unless it is blank or the same as the newest one. Returns the list.
export function recordNoteVersion(chunkId, field, text, now = Date.now()) {
const list = listNoteVersions(chunkId, field);
const value = text ?? '';
if (!value.trim() || list[0]?.text === value) return list;
const next = [{ at: now, text: value }, ...list].slice(0, MAX_VERSIONS);
try { localStorage.setItem(keyFor(chunkId, field), JSON.stringify(next)); } catch { /* storage full or unavailable */ }
return next;
}
+26
View File
@@ -0,0 +1,26 @@
import { beforeEach, expect, test } from 'vitest';
import { MAX_VERSIONS, listNoteVersions, recordNoteVersion } from './noteHistory.js';
beforeEach(() => localStorage.clear());
test('versions are kept newest first, and blanks and repeats are skipped', () => {
recordNoteVersion('c1', 'observation', 'first', 1);
recordNoteVersion('c1', 'observation', 'first', 2); // same as the newest
recordNoteVersion('c1', 'observation', ' ', 3); // blank
recordNoteVersion('c1', 'observation', 'second', 4);
expect(listNoteVersions('c1', 'observation').map((v) => v.text)).toEqual(['second', 'first']);
expect(listNoteVersions('c1', 'application')).toEqual([]);
expect(listNoteVersions('c2', 'observation')).toEqual([]);
});
test('only the latest versions are kept', () => {
for (let i = 0; i < MAX_VERSIONS + 5; i += 1) recordNoteVersion('c1', 'observation', `v${i}`, i);
const list = listNoteVersions('c1', 'observation');
expect(list).toHaveLength(MAX_VERSIONS);
expect(list[0].text).toBe(`v${MAX_VERSIONS + 4}`);
});
test('a damaged store reads as empty', () => {
localStorage.setItem('note-history:c1:observation', '{nope');
expect(listNoteVersions('c1', 'observation')).toEqual([]);
});
+15
View File
@@ -43,3 +43,18 @@ export async function downloadChapters(translation, refs, { onProgress, signal,
await Promise.all(Array.from({ length: Math.min(concurrency, refs.length) }, worker));
return { done, failed };
}
// What a study needs from the network to open offline. Your notes are always kept on this device; only the
// Bible text must have been saved. `saved` is loadSavedBooks(). -> { state: 'ready' | 'partial' | 'licensed',
// translation, missing: [{ abbrev, name }] }
export function offlineStatus(project, saved = loadSavedBooks()) {
const translation = project?.translation ?? 'BSB';
if (!canSaveOffline(translation)) return { state: 'licensed', translation, missing: [] };
const books = [];
for (const chapter of project?.chapters ?? []) {
if (chapter.bookAbbrev && !books.some((b) => b.abbrev === chapter.bookAbbrev)) books.push({ abbrev: chapter.bookAbbrev, name: chapter.book });
}
const have = saved[translation] ?? [];
const missing = books.filter((b) => !have.includes(b.abbrev));
return { state: missing.length ? 'partial' : 'ready', translation, missing };
}
+15
View File
@@ -42,3 +42,18 @@ test('stops when told to', async () => {
await downloadChapters('BSB', bookRefs('PSA'), { signal: controller.signal, concurrency: 1 });
expect(fetchMock.mock.calls.length).toBeLessThan(5);
});
import { offlineStatus } from './offline.js';
test('a study works offline once every book in it is saved in its translation', () => {
const project = { translation: 'BSB', chapters: [{ book: 'John', bookAbbrev: 'JHN' }, { book: 'John', bookAbbrev: 'JHN' }, { book: 'Titus', bookAbbrev: 'TIT' }] };
expect(offlineStatus(project, { BSB: ['JHN', 'TIT'] })).toMatchObject({ state: 'ready', missing: [] });
const partial = offlineStatus(project, { BSB: ['JHN'] });
expect(partial.state).toBe('partial');
expect(partial.missing).toEqual([{ abbrev: 'TIT', name: 'Titus' }]);
expect(offlineStatus(project, { NET: ['JHN', 'TIT'] }).state).toBe('partial'); // saved in a different translation
});
test('licensed translations cannot be saved offline', () => {
expect(offlineStatus({ translation: 'ESV', chapters: [{ book: 'John', bookAbbrev: 'JHN' }] }, {}).state).toBe('licensed');
});
+14
View File
@@ -47,3 +47,17 @@ export function thinFields(chunk) {
export function chunksNeedingWork(chunks) {
return (chunks ?? []).map((chunk) => ({ chunk, missing: thinFields(chunk) })).filter((entry) => entry.missing.length > 0);
}
// Commentary and study aids stay folded away until you've written this much of your own Observe notes.
export const OBSERVE_FIRST_MIN = 15;
export const observeFirstDone = (chunk) => ownWords(chunk, 'observation') >= OBSERVE_FIRST_MIN;
// "No, I wrote it": take one remembered paste back out, so those words count as your own again.
export function withoutPaste(chunk, field, pasted) {
const text = (pasted ?? '').trim();
const all = chunk?.aiPastes ?? {};
const list = [...(all[field] ?? [])];
const at = list.lastIndexOf(text);
if (at >= 0) list.splice(at, 1);
return { ...all, [field]: list };
}
+11
View File
@@ -56,3 +56,14 @@ test('merging two copies keeps the pastes from both', () => {
const b = { id: 'x', observation: 'b', editedAt: 1, aiPastes: { observation: ['p2'] } };
expect(mergeChunk(a, b).chunk.aiPastes.observation).toEqual(['p1', 'p2']);
});
import { withoutPaste } from './ownWork.js';
test('"No, I wrote it" takes one paste back out so the words count as yours again', () => {
const pasted = text(30);
const chunk = { observation: pasted, aiPastes: { observation: [pasted, 'other'] } };
const after = { ...chunk, aiPastes: withoutPaste(chunk, 'observation', ` ${pasted} `) };
expect(after.aiPastes.observation).toEqual(['other']);
expect(ownWords(after, 'observation')).toBe(30);
expect(withoutPaste({}, 'observation', 'x')).toEqual({ observation: [] });
});
+41 -4
View File
@@ -13,18 +13,55 @@ const snippetAround = (raw, at, len) => {
return (start > 0 ? '…' : '') + raw.slice(start, end).replace(/\s+/g, ' ') + (end < raw.length ? '…' : '');
};
const DAY = 86400000;
// "grace in Romans last year" -> { text: 'grace', book: 'Romans', since, until }. Words it understands:
// "in <book>", "last|this week|month|year" (last = the past 7 / 30 / 365 days; this year = since 1 January) and a
// four-digit year. Whatever is left is the text to look for.
export function parseSearchQuery(query, now = Date.now()) {
let text = ` ${query} `;
let book = null;
let since = null;
let until = null;
for (const b of [...bookOptions].sort((x, y) => y.name.length - x.name.length)) {
const re = new RegExp(`\\s(?:in|from)\\s+${b.name.toLowerCase().replace(/\s+/g, '\\s+')}(?=\\s)`, 'i');
if (re.test(text)) { book = b.name; text = text.replace(re, ' '); break; }
}
const rel = text.match(/\s(last|this)\s+(week|month|year)(?=\s)/i);
if (rel) {
const [, which, unit] = rel;
const span = { week: 7, month: 30, year: 365 }[unit.toLowerCase()];
since = which.toLowerCase() === 'last' ? now - span * DAY : (unit.toLowerCase() === 'year' ? new Date(new Date(now).getFullYear(), 0, 1).getTime() : now - span * DAY);
text = text.replace(rel[0], ' ');
}
const year = text.match(/\s(20\d\d)(?=\s)/);
if (year) {
since = new Date(Number(year[1]), 0, 1).getTime();
until = new Date(Number(year[1]) + 1, 0, 1).getTime();
text = text.replace(year[0], ' ');
}
return { text: text.replace(/\s+/g, ' ').trim(), book, since, until };
}
// projects: full project objects. -> [{ projectId, projectTitle, chunkId, ref, field, snippet }], at most
// `limit`, one hit per chunk (the first field that matches).
export function searchNotes(projects, query, { limit = 20 } = {}) {
const q = query.trim().toLowerCase();
if (q.length < 3) return [];
export function searchNotes(projects, query, { limit = 20, now = Date.now() } = {}) {
const parsed = parseSearchQuery(query, now);
const q = parsed.text.toLowerCase();
const filtered = !!(parsed.book || parsed.since);
if (q.length < 3 && !(filtered && q.length === 0)) return [];
const hits = [];
for (const project of projects) {
for (const [chapterIndex, chapter] of (project.chapters ?? []).entries()) {
if (parsed.book && chapter.book !== parsed.book) continue;
for (const chunk of chapter.chunks ?? []) {
const when = chunk.editedAt ?? project.lastEdited ?? 0;
if (parsed.since && when < parsed.since) continue;
if (parsed.until && when >= parsed.until) continue;
for (const [field, label] of NOTE_FIELDS) {
const raw = chunk[field] ?? '';
const at = raw.toLowerCase().indexOf(q);
if (!q && !raw.trim()) continue; // a passage/date search lists chunks that have something written
const at = q ? raw.toLowerCase().indexOf(q) : 0;
if (at === -1) continue;
hits.push({
projectId: project.id, projectTitle: project.title, chunkId: chunk.id,
+22
View File
@@ -40,3 +40,25 @@ test('finds highlights by their words or colour name, and bookmarks by reference
expect(searchMarks(marks, 'promise')[0]).toMatchObject({ kind: 'highlight' });
expect(searchMarks(marks, 'john 3')[0]).toMatchObject({ kind: 'bookmark', chapter: 3, verse: 16 });
});
import { parseSearchQuery as parseQ } from './personalSearch.js';
test('search understands "in <book>" and time words', () => {
const now = new Date('2026-10-14T12:00:00Z').getTime();
const q = parseQ('grace in Romans last year', now);
expect(q.text).toBe('grace');
expect(q.book).toBe('Romans');
expect(q.since).toBe(now - 365 * 86400000);
expect(parseQ('hope 2025', now)).toMatchObject({ text: 'hope', since: new Date(2025, 0, 1).getTime(), until: new Date(2026, 0, 1).getTime() });
expect(parseQ('plain words', now)).toMatchObject({ text: 'plain words', book: null, since: null });
});
test('a passage and date search filters notes, even with no words to match', () => {
const now = new Date('2026-10-14T12:00:00Z').getTime();
const day = 86400000;
const make = (book, editedAt, observation) => ({ id: book + editedAt, title: 'S', lastEdited: now, chapters: [{ book, bookAbbrev: book.slice(0, 3), chapter: '1', chunks: [{ id: `${book}${editedAt}`, startVerse: 1, endVerse: 3, observation, editedAt }] }] });
const projects = [make('Romans', now - 10 * day, 'grace abounds'), make('Romans', now - 400 * day, 'grace long ago'), make('John', now - 10 * day, 'grace again')];
expect(searchNotes(projects, 'grace in Romans last year', { now })).toHaveLength(1);
expect(searchNotes(projects, 'in John last month', { now })).toHaveLength(1);
expect(searchNotes(projects, 'grace', { now })).toHaveLength(3);
});
+19
View File
@@ -41,3 +41,22 @@ export const paceText = (p) => {
if (Math.abs(d) < 20) return 'On pace';
return d > 0 ? `${formatClock(d)} ahead` : `${formatClock(-d)} behind`;
};
// How the planned minutes compare with the time you have. `limit` is the session length (null/0 = not set).
// -> { state: 'unset' | 'under' | 'on' | 'over', diff (minutes, + to spare, - over), text }
export function planStatus(total, limit) {
const t = Number(total) || 0;
const l = Number(limit) || 0;
if (!l) return { state: 'unset', diff: 0, text: '' };
const diff = l - t;
if (t === 0) return { state: 'under', diff, text: `No minutes planned yet for ${l} min` };
if (diff === 0) return { state: 'on', diff, text: `${t} of ${l} min planned, right on time` };
if (diff > 0) return { state: 'under', diff, text: `${t} of ${l} min planned, ${diff} min to spare` };
return { state: 'over', diff, text: `${t} of ${l} min planned, ${-diff} min over` };
}
// The first point that carries the plan past the session length, or null.
export function firstOverPoint(steps, limit) {
const l = Number(limit) || 0;
return l ? steps.find((s) => s.plannedEnd > l) ?? null : null;
}
+17
View File
@@ -28,3 +28,20 @@ test('pace: ahead before a point\'s window, behind after it, on pace inside it',
expect(pacing(outlineSteps([n('x', 'No minutes')]), 0, 10)).toBeNull();
expect(paceText(null)).toBe('');
});
import { firstOverPoint, outlineSteps as stepsOf, planStatus } from './present.js';
test('the plan is compared with the session length', () => {
expect(planStatus(40, null).state).toBe('unset');
expect(planStatus(0, 45)).toMatchObject({ state: 'under' });
expect(planStatus(42, 45)).toEqual({ state: 'under', diff: 3, text: '42 of 45 min planned, 3 min to spare' });
expect(planStatus(45, 45).state).toBe('on');
expect(planStatus(52, 45)).toEqual({ state: 'over', diff: -7, text: '52 of 45 min planned, 7 min over' });
});
test('the first point that runs past the session length is found', () => {
const tree = [{ id: 'a', text: 'A', minutes: 20, children: [] }, { id: 'b', text: 'B', minutes: 20, children: [] }, { id: 'c', text: 'C', minutes: 10, children: [] }];
expect(firstOverPoint(stepsOf(tree), 45).id).toBe('c');
expect(firstOverPoint(stepsOf(tree), 60)).toBeNull();
expect(firstOverPoint(stepsOf(tree), null)).toBeNull();
});
+32
View File
@@ -0,0 +1,32 @@
// Present mode on two screens: the presenter window sends what the audience may see to a second "audience"
// window (the one dragged to the projector) over a BroadcastChannel. Private things (a point's speaker notes, the
// script, planned minutes, the timer) are never put in the message, so the audience window cannot show them.
export const CHANNEL = 'bible-study-present';
export const AUDIENCE_PARAM = 'audience';
export const audienceUrl = (loc = window.location) => `${loc.origin}${loc.pathname}?${AUDIENCE_PARAM}=1`;
export const isAudienceWindow = (search = typeof window === 'undefined' ? '' : window.location.search) => new URLSearchParams(search).has(AUDIENCE_PARAM);
// null where the browser has no BroadcastChannel (then there is no audience window, only the one screen)
export function openChannel() {
try { return typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL); } catch { return null; }
}
// The parts of an outline point that belong on the projector.
export function publicStep(step) {
if (!step) return null;
return {
label: step.label ?? '', kind: step.kind ?? '', text: step.text ?? '', ref: step.ref ?? '',
scripture: step.scripture?.text ? { text: step.scripture.text, ref: step.scripture.ref ?? '' } : null,
attachments: (step.attachments ?? []).map((a) => ({ type: a.type, label: a.label })),
};
}
// The message the audience window draws from. In script mode the audience gets only the title (the script is for the
// speaker); `blank` blacks the projector out.
export function audienceState({ title, subtitle = '', mode, step, index = 0, total = 0, dark = true, fontPx = 40, blank = false }) {
return {
type: 'state', title, subtitle, mode, dark, fontPx, blank: !!blank, index, total,
step: mode === 'outline' ? publicStep(step) : null,
};
}
+28
View File
@@ -0,0 +1,28 @@
import { test, expect } from 'vitest';
import { audienceState, audienceUrl, isAudienceWindow, publicStep } from './presentSync.js';
const step = { id: 'a', label: 'I.', depth: 0, text: 'Welcome', ref: 'Rom 1:16', note: 'SECRET speaker note', minutes: 7, plannedEnd: 7, kind: 'quote',
scripture: { text: 'For I am not ashamed', ref: 'Romans 1:16', translation: 'BSB' }, attachments: [{ id: 'x', type: 'word', label: 'pistis', detail: 'PRIVATE detail' }] };
test('what the audience gets of a point never includes the private note, minutes or details', () => {
const pub = publicStep(step);
expect(pub).toEqual({ label: 'I.', kind: 'quote', text: 'Welcome', ref: 'Rom 1:16', scripture: { text: 'For I am not ashamed', ref: 'Romans 1:16' }, attachments: [{ type: 'word', label: 'pistis' }] });
const message = JSON.stringify(audienceState({ title: 'Romans', mode: 'outline', step, index: 0, total: 3 }));
expect(message).not.toMatch(/SECRET|PRIVATE|minutes|plannedEnd/);
expect(message).toContain('Welcome');
});
test('in script mode the audience gets the title only, and blank blacks the screen out', () => {
const state = audienceState({ title: 'Romans 8', subtitle: 'Session 1', mode: 'script', step, blank: true });
expect(state.step).toBeNull();
expect(state.blank).toBe(true);
expect(JSON.stringify(state)).not.toMatch(/SECRET|Welcome/);
expect(publicStep(null)).toBeNull();
});
test('the audience window is recognised by its address', () => {
expect(audienceUrl({ origin: 'https://study.test', pathname: '/' })).toBe('https://study.test/?audience=1');
expect(isAudienceWindow('?audience=1')).toBe(true);
expect(isAudienceWindow('?share=abc')).toBe(false);
expect(isAudienceWindow('')).toBe(false);
});
+1
View File
@@ -2,6 +2,7 @@
const FIELD_NAMES = {
observation: 'Observe', interpretation: 'Interpret', application: 'Apply', generalNotes: 'Background', finalScript: 'Script',
discussionQuestions: 'Questions', episodeNumber: 'Session number', episodeTitle: 'Session title',
teachBack: 'Teach it back', attendees: 'Who came', groupNotes: 'Group notes',
};
const same = (a, b) => JSON.stringify(a ?? '') === JSON.stringify(b ?? '');
+78
View File
@@ -0,0 +1,78 @@
// Slides for a session (or the whole study): a title slide, then for each session its passage, one slide per outline
// point (with the verse the point quotes) and the discussion questions. Built as a printable 16:9 page set ("Save as
// PDF" in the print dialog) from the same sessions, outlines and questions as the lesson handout.
import { getChunkVerseEntries } from './bibleUtils.jsx';
import { flatten } from './outline.js';
import { compactPassages } from './passageSummary.js';
import { buildChapterSummary } from './projectStorage.js';
import { outlineForSession, questionsFor, sessionsFor } from './handout.js';
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// -> [{ kind: 'title' | 'section' | 'passage' | 'point' | 'questions', ... }] in the order they are shown.
// ids: the chunk ids to include, or null for every session.
export function buildSlides(project, ids = null) {
const slides = [{ kind: 'title', title: project.title ?? '', subtitle: compactPassages(buildChapterSummary(project)) }];
for (const session of sessionsFor(project).filter((s) => !ids || ids.includes(s.chunk.id))) {
const { chunk, chapterIndex, number, reference } = session;
const name = (chunk.episodeTitle ?? '').trim();
slides.push({ kind: 'section', title: name || reference, subtitle: name ? reference : '', number: (chunk.episodeNumber ?? '').toString().trim() || number });
const verses = getChunkVerseEntries(project, chapterIndex, chunk);
if (verses.length) slides.push({ kind: 'passage', reference, translation: project.translation ?? 'BSB', verses: verses.map((v) => ({ number: v.number, text: v.text })) });
for (const { node, label, depth } of flatten(outlineForSession(project, chunk))) {
slides.push({ kind: 'point', label, depth, text: node.text ?? '', ref: node.ref ?? '', quote: node.scripture?.text ? { text: node.scripture.text, ref: node.scripture.ref ?? '' } : null });
}
const q = questionsFor(chunk);
if (q.items.length) slides.push({ kind: 'questions', title: q.kind === 'questions' ? 'Discussion' : 'Think about it', items: q.items });
}
return slides;
}
// Longer text gets a smaller size so it stays on the slide.
const sizeFor = (chars, steps) => steps.find(([max]) => chars <= max)?.[1] ?? steps[steps.length - 1][1];
function slideHtml(s) {
if (s.kind === 'title') return `<section class="slide center"><h1>${esc(s.title)}</h1>${s.subtitle ? `<p class="sub">${esc(s.subtitle)}</p>` : ''}</section>`;
if (s.kind === 'section') return `<section class="slide center"><p class="kicker">Session ${esc(s.number)}</p><h1>${esc(s.title)}</h1>${s.subtitle ? `<p class="sub">${esc(s.subtitle)}</p>` : ''}</section>`;
if (s.kind === 'passage') {
const chars = s.verses.reduce((n, v) => n + v.text.length, 0);
const size = sizeFor(chars, [[300, 34], [550, 28], [900, 23], [1400, 19], [Infinity, 16]]);
return `<section class="slide"><p class="kicker">${esc(s.reference)} · ${esc(s.translation)}</p><div class="passage" style="font-size:${size}pt">${s.verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${esc(v.text)}</p>`).join('')}</div></section>`;
}
if (s.kind === 'point') {
const chars = s.text.length + (s.quote?.text.length ?? 0);
const size = sizeFor(chars, [[80, 46], [160, 38], [300, 30], [Infinity, 24]]);
return `<section class="slide"><p class="kicker">${s.depth ? 'Point ' : ''}${esc(s.label)}</p><h2 style="font-size:${size}pt">${esc(s.text || '(untitled)')}</h2>${s.ref ? `<p class="sub">${esc(s.ref)}</p>` : ''}${s.quote ? `<blockquote style="font-size:${Math.max(16, size - 14)}pt">“${esc(s.quote.text)}”${s.quote.ref ? ` <span>— ${esc(s.quote.ref)}</span>` : ''}</blockquote>` : ''}</section>`;
}
return `<section class="slide"><p class="kicker">${esc(s.title)}</p><ol class="qs">${s.items.map((i) => `<li>${esc(i)}</li>`).join('')}</ol></section>`;
}
export function buildSlidesHtml(project, ids = null, { dark = false } = {}) {
const bg = dark ? '#16130f' : '#faf7f0';
const fg = dark ? '#f2efe8' : '#2b2620';
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(project.title)} — slides</title><style>
@page { size: 13.333in 7.5in; margin: 0; }
* { box-sizing: border-box; }
html, body { margin: 0; background: ${bg}; color: ${fg}; font-family: Georgia, 'Times New Roman', serif; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.slide { width: 13.333in; height: 7.5in; padding: 0.7in 0.9in; page-break-after: always; break-after: page; display: flex; flex-direction: column; justify-content: center; overflow: hidden; background: ${bg}; }
.slide:last-child { page-break-after: auto; break-after: auto; }
.center { align-items: center; text-align: center; }
.kicker { margin: 0 0 .5em; font: 600 14pt -apple-system, Helvetica, Arial, sans-serif; letter-spacing: .16em; text-transform: uppercase; color: #a84e27; }
h1 { margin: 0; font-size: 54pt; font-weight: 600; line-height: 1.15; }
h2 { margin: 0; font-weight: 600; line-height: 1.25; }
.sub { margin: .6em 0 0; font: 20pt -apple-system, Helvetica, Arial, sans-serif; opacity: .7; }
.passage p { margin: .35em 0; line-height: 1.45; } sup { color: #a84e27; font: 60% -apple-system, Helvetica, Arial, sans-serif; }
blockquote { margin: .9em 0 0; padding-left: .8em; border-left: 6px solid #a84e27; font-style: italic; line-height: 1.4; } blockquote span { font-style: normal; opacity: .65; }
.qs { font-size: 30pt; line-height: 1.4; padding-left: 1.2em; margin: 0; } .qs li { margin: .5em 0; }
@media screen { body { padding: 1em; } .slide { margin: 0 auto 1em; box-shadow: 0 2px 12px rgba(0,0,0,.2); } }
</style></head><body>${buildSlides(project, ids).map(slideHtml).join('')}</body></html>`;
}
export function printSlides(project, ids = null, options) {
const win = window.open('', '_blank');
if (!win) return false;
win.document.write(buildSlidesHtml(project, ids, options));
win.document.close();
win.onload = () => { win.focus(); win.print(); };
return true;
}
+34
View File
@@ -0,0 +1,34 @@
import { test, expect } from 'vitest';
import { buildSlides, buildSlidesHtml } from './slides.js';
const project = () => ({
id: 'p', title: 'John <study>', translation: 'BSB', outline: [],
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved the world.' }, { number: 17, text: 'God sent not his Son to condemn.' }], chunks: [
{ id: 'a', startVerse: 16, endVerse: 17, episodeTitle: 'God so loved', discussionQuestions: 'Why did he give?\nWho is "the world"?',
outline: [{ id: 'n1', text: 'Love gives', children: [{ id: 'n2', text: 'He gave', scripture: { text: 'For God so loved', ref: 'John 3:16' }, children: [] }] }] },
{ id: 'b', startVerse: 17, endVerse: 17, application: 'Trust him.', outline: [] },
] }],
});
test('slides run title, then per session: section, passage, points, questions', () => {
const kinds = buildSlides(project()).map((s) => s.kind);
expect(kinds).toEqual(['title', 'section', 'passage', 'point', 'point', 'questions', 'section', 'passage', 'questions']);
const slides = buildSlides(project());
expect(slides[1]).toMatchObject({ title: 'God so loved', subtitle: 'John 3:16–17' });
expect(slides[4]).toMatchObject({ depth: 1, text: 'He gave', quote: { ref: 'John 3:16' } });
expect(slides[8]).toMatchObject({ title: 'Think about it', items: ['Trust him.'] }); // Application notes stand in for questions
});
test('one session only', () => {
const kinds = buildSlides(project(), ['b']).map((s) => s.kind);
expect(kinds).toEqual(['title', 'section', 'passage', 'questions']);
});
test('the printable page has a 16:9 slide per entry, escapes text, and can be dark', () => {
const html = buildSlidesHtml(project());
expect(html.match(/class="slide/g)).toHaveLength(9);
expect(html).toContain('John &lt;study&gt;');
expect(html).toContain('size: 13.333in 7.5in');
expect(html).not.toContain('<study>');
expect(buildSlidesHtml(project(), null, { dark: true })).toContain('#16130f');
});