From 2c7c7145c4ef07ef9db83d95fdc22af84580e828 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:08:21 -0400 Subject: [PATCH] Teaching: hide-notes-until share, session length check, slides export - A study share link can hide the owner's notes until a chosen time: the server sends only passages and discussion questions until then, with a banner in the shared view (new share_reveal column, migrated on start). - Outline: a session length with "N of M min planned, X min over" and a marker on the point where the plan runs past it. - Export > Slides: printable 16:9 slides (title, passage, outline points with their verse, discussion questions) for one session or all; light or dark. Co-Authored-By: Claude Sonnet 5.5 --- server/db.js | 26 ++++++----- server/routes/sharing.js | 20 +++++---- server/shareLinks.js | 24 ++++++++++ server/shareLinks.test.js | 33 ++++++++++++++ src/App.jsx | 1 + src/components/LinkRules.jsx | 24 +++++++--- src/components/SlidesExport.jsx | 30 +++++++++++++ src/hooks/useShareLink.js | 9 ++-- src/pages/ExportPage.jsx | 5 ++- src/pages/OutlinePage.jsx | 16 ++++++- src/utils/present.js | 19 ++++++++ src/utils/present.test.js | 17 +++++++ src/utils/slides.js | 78 +++++++++++++++++++++++++++++++++ src/utils/slides.test.js | 34 ++++++++++++++ 14 files changed, 303 insertions(+), 33 deletions(-) create mode 100644 src/components/SlidesExport.jsx create mode 100644 src/utils/slides.js create mode 100644 src/utils/slides.test.js diff --git a/server/db.js b/server/db.js index 2687dd6..63b95a6 100644 --- a/server/db.js +++ b/server/db.js @@ -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). */ diff --git a/server/routes/sharing.js b/server/routes/sharing.js index ee8f68d..071658e 100644 --- a/server/routes/sharing.js +++ b/server/routes/sharing.js @@ -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.' }); diff --git a/server/shareLinks.js b/server/shareLinks.js index 076dfb7..00c548e 100644 --- a/server/shareLinks.js +++ b/server/shareLinks.js @@ -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); +} diff --git a/server/shareLinks.test.js b/server/shareLinks.test.js index fdca999..1e47bff 100644 --- a/server/shareLinks.test.js +++ b/server/shareLinks.test.js @@ -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); +}); diff --git a/src/App.jsx b/src/App.jsx index a4947b6..a453301 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -608,6 +608,7 @@ function AppRouter() { return (
+ {sharedProject.hiddenUntil ? `Notes appear ${new Date(sharedProject.hiddenUntil).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}. For now: the passages and questions. ` : ''} 🔗 Read-only shared view —{' '} go to Bible Study Project
diff --git a/src/components/LinkRules.jsx b/src/components/LinkRules.jsx index 285aa9a..62bdd17 100644 --- a/src/components/LinkRules.jsx +++ b/src/components/LinkRules.jsx @@ -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 (
-

{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}

+

{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}{showReveal && revealAt && revealAt > Date.now() ? ` · Your notes appear ${new Date(revealAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}` : ''}

+ {showReveal && ( + + )} + {showReveal && (reveal ?? toLocalInput(revealAt > Date.now() ? revealAt : null)) && ( + + )} {hasPasscode && }
-

Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.

+

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.' : ''}

); } diff --git a/src/components/SlidesExport.jsx b/src/components/SlidesExport.jsx new file mode 100644 index 0000000..6bc633a --- /dev/null +++ b/src/components/SlidesExport.jsx @@ -0,0 +1,30 @@ +// Export → 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 ( +
+

Slides

+

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.

+
+ + + +
+ {blocked &&

Allow pop-ups for this site to print the slides.

} +
+ ); +} diff --git a/src/hooks/useShareLink.js b/src/hooks/useShareLink.js index a6a6d37..abdfc19 100644 --- a/src/hooks/useShareLink.js +++ b/src/hooks/useShareLink.js @@ -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 }; } diff --git a/src/pages/ExportPage.jsx b/src/pages/ExportPage.jsx index b5dbee7..f05a8f2 100644 --- a/src/pages/ExportPage.jsx +++ b/src/pages/ExportPage.jsx @@ -1,6 +1,7 @@ // The Export stage: take the finished study out as a file, or share it read-only. import { useApp } from '../context/AppContext.js'; import { useShareLink } from '../hooks/useShareLink.js'; +import SlidesExport from '../components/SlidesExport.jsx'; import ImportNotes from '../components/ImportNotes.jsx'; import HandoutExport from '../components/HandoutExport.jsx'; import SessionSchedule from '../components/SessionSchedule.jsx'; @@ -53,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)]" />
- + {share.shareError &&

{share.shareError}

} @@ -64,6 +65,8 @@ export default function ExportPage() { + + diff --git a/src/pages/OutlinePage.jsx b/src/pages/OutlinePage.jsx index e0ad971..18e621d 100644 --- a/src/pages/OutlinePage.jsx +++ b/src/pages/OutlinePage.jsx @@ -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() { {timeSummary(tree) && {timeSummary(tree)}} + + {plan.state !== 'unset' && {plan.text}} {scopeChunk && } @@ -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) && (

{node.kind && {kindLabel(node.kind)}} {mins && {node.minutes} min} + {overPoint?.id === node.id && Runs past {sessionMinutes} min here} {node.ref && {node.ref}} {linked && node.chunkId !== scope && }

diff --git a/src/utils/present.js b/src/utils/present.js index c8133a1..b9af942 100644 --- a/src/utils/present.js +++ b/src/utils/present.js @@ -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; +} diff --git a/src/utils/present.test.js b/src/utils/present.test.js index 5b8abf9..4d37f25 100644 --- a/src/utils/present.test.js +++ b/src/utils/present.test.js @@ -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(); +}); diff --git a/src/utils/slides.js b/src/utils/slides.js new file mode 100644 index 0000000..adbe55c --- /dev/null +++ b/src/utils/slides.js @@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 `

${esc(s.title)}

${s.subtitle ? `

${esc(s.subtitle)}

` : ''}
`; + if (s.kind === 'section') return `

Session ${esc(s.number)}

${esc(s.title)}

${s.subtitle ? `

${esc(s.subtitle)}

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

${esc(s.reference)} · ${esc(s.translation)}

${s.verses.map((v) => `

${esc(v.number)} ${esc(v.text)}

`).join('')}
`; + } + 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 `

${s.depth ? 'Point ' : ''}${esc(s.label)}

${esc(s.text || '(untitled)')}

${s.ref ? `

${esc(s.ref)}

` : ''}${s.quote ? `
“${esc(s.quote.text)}”${s.quote.ref ? ` — ${esc(s.quote.ref)}` : ''}
` : ''}
`; + } + return `

${esc(s.title)}

    ${s.items.map((i) => `
  1. ${esc(i)}
  2. `).join('')}
`; +} + +export function buildSlidesHtml(project, ids = null, { dark = false } = {}) { + const bg = dark ? '#16130f' : '#faf7f0'; + const fg = dark ? '#f2efe8' : '#2b2620'; + return `${esc(project.title)} — slides${buildSlides(project, ids).map(slideHtml).join('')}`; +} + +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; +} diff --git a/src/utils/slides.test.js b/src/utils/slides.test.js new file mode 100644 index 0000000..f966e76 --- /dev/null +++ b/src/utils/slides.test.js @@ -0,0 +1,34 @@ +import { test, expect } from 'vitest'; +import { buildSlides, buildSlidesHtml } from './slides.js'; + +const project = () => ({ + id: 'p', title: 'John ', 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 <study>'); + expect(html).toContain('size: 13.333in 7.5in'); + expect(html).not.toContain(''); + expect(buildSlidesHtml(project(), null, { dark: true })).toContain('#16130f'); +});