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

+60
View File
@@ -0,0 +1,60 @@
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, afterEach } from 'vitest';
import PresentMode from './PresentMode.jsx';
afterEach(() => vi.useRealTimers());
const n = (id, text, minutes, extra = {}) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children: [], ...extra });
const tree = [n('a', 'Welcome', 2, { note: 'Say hello' }), n('b', 'The gospel', 10, { ref: 'Rom 1:16' })];
test('the outline is stepped through a point at a time, with the keyboard or the buttons', async () => {
const user = userEvent.setup();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
expect(screen.getByText('Welcome')).toBeInTheDocument();
expect(screen.getByText('Say hello')).toBeInTheDocument();
expect(screen.getByText(/Point 1 of 2/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled();
await user.keyboard('{ArrowRight}');
expect(screen.getByText('The gospel')).toBeInTheDocument();
expect(screen.getByText('Rom 1:16')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
await user.click(screen.getByRole('button', { name: /back/i }));
expect(screen.getByText('Welcome')).toBeInTheDocument();
});
test('the timer runs, pauses and resets, and tells you how you are doing against the plan', async () => {
vi.useFakeTimers();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
const group = screen.getByRole('group', { name: 'Timer' });
const click = (name) => act(() => { screen.getByRole('button', { name }).click(); });
click('Start');
act(() => { vi.advanceTimersByTime(65_000); });
expect(group).toHaveTextContent('1:05');
expect(screen.getByRole('status')).toHaveTextContent(/On pace/); // a minute in, on a 2-minute point
click('Pause');
act(() => { vi.advanceTimersByTime(10_000); });
expect(group).toHaveTextContent('1:05'); // paused
click('Reset');
expect(group).toHaveTextContent('0:00');
});
test('the text size can be changed and the choice is remembered', async () => {
const user = userEvent.setup();
localStorage.clear();
render(<PresentMode mode="script" title="Romans 8" script="In the beginning" onClose={() => {}} />);
const text = screen.getByText('In the beginning');
expect(text.style.fontSize).toBe('34px');
await user.click(screen.getByRole('button', { name: 'Larger text' }));
expect(text.style.fontSize).toBe('40px');
expect(localStorage.getItem('present-size')).toBe('40');
});
test('Escape and Close both leave, and an empty script says what to do', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(<PresentMode mode="script" title="T" script=" " onClose={onClose} />);
expect(screen.getByText(/no script to present yet/i)).toBeInTheDocument();
await user.keyboard('{Escape}');
await user.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(2);
});