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

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

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

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
import { test, expect } from 'vitest';
import { buildHandoutHtml, outlineForSession, questionsFor, sessionsFor } from './handout.js';
const chunk = (id, s, e, x = {}) => ({ id, startVerse: s, endVerse: e, observation: '', interpretation: '', application: '', ...x });
const project = {
title: 'Romans 8 <study>', series: 'Romans', translation: 'BSB',
outline: [{ id: 'o1', text: 'No condemnation', ref: '', chunkId: 'a', children: [{ id: 'o2', text: 'The Spirit gives life', ref: 'Rom 8:2', chunkId: null, children: [] }] }],
chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'There is now no condemnation.' }, { number: 2, text: 'For the law of the Spirit of life.' }, { number: 12, text: 'So then, brothers.' }], chunks: [
chunk('a', 1, 2, { episodeNumber: '1', episodeTitle: 'Free at last', discussionQuestions: '1. What is condemnation?\n- Why does it end?', application: 'Rest in grace' }),
chunk('b', 12, 12, { application: '- Walk by the Spirit\n- Put sin to death' }),
] }],
};
test('sessions are numbered in order with their passages', () => {
expect(sessionsFor(project).map((s) => [s.number, s.reference])).toEqual([[1, 'Romans 8:1–2'], [2, 'Romans 8:12']]);
});
test('questions written for a session are used; otherwise the Application notes become prompts', () => {
expect(questionsFor(project.chapters[0].chunks[0])).toEqual({ kind: 'questions', items: ['What is condemnation?', 'Why does it end?'] });
expect(questionsFor(project.chapters[0].chunks[1])).toEqual({ kind: 'think', items: ['Walk by the Spirit', 'Put sin to death'] });
expect(questionsFor(chunk('z', 1, 1))).toEqual({ kind: 'none', items: [] });
});
test('a session uses the study outline points linked to it', () => {
expect(outlineForSession(project, project.chapters[0].chunks[0])).toHaveLength(1);
expect(outlineForSession(project, project.chapters[0].chunks[1])).toEqual([]);
expect(outlineForSession(project, { id: 'a', outline: [{ id: 'x', text: 'Own', children: [] }] })[0].id).toBe('x');
});
test('the handout has a page per session with passage, outline and questions, and escapes text', () => {
const html = buildHandoutHtml(project);
expect(html.match(/class="page"/g)).toHaveLength(2);
expect(html).toContain('Session 1: Free at last');
expect(html).toContain('<sup>1</sup> There is now no condemnation.');
expect(html).toContain('The Spirit gives life');
expect(html).toContain('Discussion questions');
expect(html).toContain('Think about it');
expect(html).toContain('Romans 8 &lt;study&gt;'); // no raw markup from the title
expect(html).not.toContain('<study>');
expect(html).toContain('class="rule"');
});
test('one session only, and no writing lines when switched off', () => {
const html = buildHandoutHtml(project, ['b'], { answerLines: false });
expect(html.match(/class="page"/g)).toHaveLength(1);
expect(html).toContain('Session 2');
expect(html).not.toContain('class="rule"');
});