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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 13:08:21 -04:00
1 parent 67645cf1df
commit 2c7c7145c4
14 files changed
+303 -33

No files matched your search

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