Invite-only sign-up, present mode, series, handouts, templates, dictation, progress, sharing
Build and Push Docker Image / build-and-push (push) Successful in 6m47s
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:
1 parent
88bc59bac0
commit
54cb1c217d
73 files changed
+2739
-134
No files matched your search
@@ -0,0 +1,85 @@
|
||||
// A one-page-per-session handout for a group: the passage, the outline, discussion questions and room to write.
|
||||
// It opens as a print-friendly page ("Save as PDF" in the print dialog).
|
||||
import { formatChunkReference, getChunkVerseEntries } from './bibleUtils.jsx';
|
||||
import { flatten } from './outline.js';
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const lines = (text) => String(text ?? '').split('\n').map((l) => l.replace(/^\s*(?:[-*•–]|\d{1,2}[.)])\s+/, '').trim()).filter(Boolean);
|
||||
|
||||
// Every session (chunk) of the study, in order, with where it sits.
|
||||
export function sessionsFor(project) {
|
||||
const out = [];
|
||||
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
|
||||
for (const chunk of chapter.chunks ?? []) out.push({ chunk, chapterIndex, number: out.length + 1, reference: formatChunkReference(project, chapterIndex, chunk, '–') });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// The outline points for a session: its own outline, else the study outline's points linked to it.
|
||||
export function outlineForSession(project, chunk) {
|
||||
if ((chunk.outline ?? []).length) return chunk.outline;
|
||||
const linked = (project.outline ?? []).filter((n) => n.chunkId === chunk.id);
|
||||
return linked.length ? linked : [];
|
||||
}
|
||||
|
||||
// The questions to print: the ones written for the session, else its Application notes as "Think about" prompts.
|
||||
export function questionsFor(chunk) {
|
||||
const own = lines(chunk.discussionQuestions);
|
||||
if (own.length) return { kind: 'questions', items: own };
|
||||
const fallback = lines(chunk.application);
|
||||
return fallback.length ? { kind: 'think', items: fallback } : { kind: 'none', items: [] };
|
||||
}
|
||||
|
||||
function sessionHtml(project, session, { answerLines }) {
|
||||
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 blank = (n) => Array.from({ length: n }, () => '<div class="rule"></div>').join('');
|
||||
return `<section class="page">
|
||||
<header>
|
||||
<p class="kicker">${esc(series || project.title)}</p>
|
||||
<h1>Session ${esc(num)}${title ? `: ${esc(title)}` : ''}</h1>
|
||||
<p class="ref">${esc(reference)} <span>(${esc(project.translation ?? 'BSB')})</span></p>
|
||||
<p class="meta">Name: ____________________________ Date: ________________</p>
|
||||
</header>
|
||||
${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)}` : ''}
|
||||
<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 } = {}) {
|
||||
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; }
|
||||
body { font-family: Georgia, 'Times New Roman', serif; color: #1f1b16; line-height: 1.5; margin: 0; font-size: 12.5pt; }
|
||||
.page { page-break-after: always; min-height: 95vh; position: relative; }
|
||||
.page:last-child { page-break-after: auto; }
|
||||
.kicker { margin: 0; font: 600 9.5pt -apple-system, Helvetica, Arial, sans-serif; letter-spacing: .14em; text-transform: uppercase; color: #8a4a2a; }
|
||||
h1 { margin: .15em 0 0; font-size: 22pt; font-weight: 600; }
|
||||
.ref { margin: .2em 0 .4em; font-size: 13pt; color: #8a4a2a; font-weight: 600; } .ref span { color: #6b6458; font-weight: 400; font-size: 10pt; }
|
||||
.meta { color: #6b6458; font: 10pt -apple-system, Helvetica, Arial, sans-serif; margin: .2em 0 1em; }
|
||||
h2 { font: 600 10pt -apple-system, Helvetica, Arial, sans-serif; letter-spacing: .12em; text-transform: uppercase; color: #6b6458; border-bottom: 1px solid #d8cfbb; padding-bottom: 3px; margin: 1.3em 0 .5em; }
|
||||
.passage { background: #f6f1e6; border-left: 3px solid #a84e27; padding: .4em 1em; } .passage p { margin: .35em 0; } sup { color: #a84e27; font-size: 70%; font-family: -apple-system, Helvetica, Arial, sans-serif; }
|
||||
.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; }
|
||||
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>`;
|
||||
}
|
||||
|
||||
export function printHandout(project, ids = null, options) {
|
||||
const win = window.open('', '_blank');
|
||||
if (!win) return false;
|
||||
win.document.write(buildHandoutHtml(project, ids, options));
|
||||
win.document.close();
|
||||
win.onload = () => { win.focus(); win.print(); };
|
||||
return true;
|
||||
}
|
||||
@@ -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 <study>'); // 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"');
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// A calendar file (.ics) any calendar app can import: one event per entry. Dates are plain calendar days
|
||||
// (all-day events) unless a time is given, in which case the event is at that clock time wherever the file is opened.
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const day = (d) => `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
||||
const stamp = (d) => `${day(d)}T${pad(d.getHours())}${pad(d.getMinutes())}00`;
|
||||
const utcStamp = (d) => `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
||||
|
||||
// Escapes text for an ICS field, and folds long lines the way the format requires (75 characters).
|
||||
export const icsText = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
|
||||
export function fold(line) {
|
||||
const out = [];
|
||||
let rest = line;
|
||||
while (rest.length > 75) { out.push(rest.slice(0, 75)); rest = ` ${rest.slice(75)}`; }
|
||||
out.push(rest);
|
||||
return out.join('\r\n');
|
||||
}
|
||||
|
||||
// events: [{ date: Date, title, description?, time?: 'HH:MM', minutes?: number }] -> the file's text
|
||||
export function buildIcs(events, { name = 'Bible study', now = new Date() } = {}) {
|
||||
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Bible Study Project//EN', 'CALSCALE:GREGORIAN', `X-WR-CALNAME:${icsText(name)}`];
|
||||
events.forEach((e, i) => {
|
||||
lines.push('BEGIN:VEVENT', `UID:${utcStamp(now)}-${i}@bible-study-project`, `DTSTAMP:${utcStamp(now)}`);
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(e.time ?? '');
|
||||
if (m) {
|
||||
const start = new Date(e.date.getFullYear(), e.date.getMonth(), e.date.getDate(), Number(m[1]), Number(m[2]));
|
||||
const end = new Date(start.getTime() + (e.minutes ?? 60) * 60_000);
|
||||
lines.push(`DTSTART:${stamp(start)}`, `DTEND:${stamp(end)}`);
|
||||
} else {
|
||||
const next = new Date(e.date.getFullYear(), e.date.getMonth(), e.date.getDate() + 1);
|
||||
lines.push(`DTSTART;VALUE=DATE:${day(e.date)}`, `DTEND;VALUE=DATE:${day(next)}`);
|
||||
}
|
||||
lines.push(`SUMMARY:${icsText(e.title)}`);
|
||||
if (e.description) lines.push(`DESCRIPTION:${icsText(e.description)}`);
|
||||
lines.push('END:VEVENT');
|
||||
});
|
||||
lines.push('END:VCALENDAR');
|
||||
return `${lines.map(fold).join('\r\n')}\r\n`;
|
||||
}
|
||||
|
||||
// The date of each of `count` sessions: the first on `start`, then every `everyDays` days.
|
||||
export function sessionDates(start, count, everyDays = 7) {
|
||||
return Array.from({ length: count }, (_, i) => new Date(start.getFullYear(), start.getMonth(), start.getDate() + i * everyDays));
|
||||
}
|
||||
|
||||
export function downloadIcs(text, fileName) {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'text/calendar;charset=utf-8' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = fileName;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { buildIcs, fold, icsText, sessionDates } from './icsFile.js';
|
||||
|
||||
const NOW = new Date(Date.UTC(2026, 9, 6, 12, 0, 0));
|
||||
|
||||
test('sessions fall on the start date and then every N days', () => {
|
||||
const d = sessionDates(new Date(2026, 9, 11), 3, 7);
|
||||
expect(d.map((x) => `${x.getMonth() + 1}/${x.getDate()}`)).toEqual(['10/11', '10/18', '10/25']);
|
||||
expect(sessionDates(new Date(2026, 11, 28), 2, 7).map((x) => `${x.getFullYear()}-${x.getMonth() + 1}-${x.getDate()}`)).toEqual(['2026-12-28', '2027-1-4']); // across the new year
|
||||
});
|
||||
|
||||
test('all-day events end the next day', () => {
|
||||
const ics = buildIcs([{ date: new Date(2026, 9, 11), title: 'Romans — Ep 1: Grace' }], { name: 'Romans', now: NOW });
|
||||
expect(ics.startsWith('BEGIN:VCALENDAR\r\n')).toBe(true);
|
||||
expect(ics).toContain('DTSTART;VALUE=DATE:20261011');
|
||||
expect(ics).toContain('DTEND;VALUE=DATE:20261012');
|
||||
expect(ics).toContain('SUMMARY:Romans — Ep 1: Grace');
|
||||
expect(ics).toContain('X-WR-CALNAME:Romans');
|
||||
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
|
||||
});
|
||||
|
||||
test('a time makes a timed event with a length', () => {
|
||||
const ics = buildIcs([{ date: new Date(2026, 9, 11), title: 'Lesson', time: '09:30', minutes: 45 }], { now: NOW });
|
||||
expect(ics).toContain('DTSTART:20261011T093000');
|
||||
expect(ics).toContain('DTEND:20261011T101500');
|
||||
});
|
||||
|
||||
test('special characters are escaped and long lines are folded', () => {
|
||||
expect(icsText('A, B; C\nD\\E')).toBe('A\\, B\\; C\\nD\\\\E');
|
||||
const folded = fold(`DESCRIPTION:${'x'.repeat(200)}`);
|
||||
expect(folded.split('\r\n').every((l) => l.length <= 76)).toBe(true);
|
||||
expect(folded.replace(/\r\n /g, '')).toBe(`DESCRIPTION:${'x'.repeat(200)}`);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Your highlights and bookmarks as a file: grouped by colour (under the names you gave the colours), each in
|
||||
// Bible order with the words you marked. Markdown for notes apps, Word for printing.
|
||||
import { COLOR_NAMES, HIGHLIGHT_COLORS, colorTitle } from './highlightColors.js';
|
||||
|
||||
// highlights: [{ bookName, bookIndex, chapter, verse, startOffset, color, text, translation }]; bookmarks: [{ bookName, bookIndex, chapter, verse, color }]
|
||||
export function buildMarksModel({ highlights = [], bookmarks = [], colorLabels = {} }) {
|
||||
const order = (a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse || (a.startOffset ?? 0) - (b.startOffset ?? 0);
|
||||
const colors = [...new Set([...HIGHLIGHT_COLORS, ...highlights.map((h) => h.color)])];
|
||||
const groups = colors
|
||||
.map((color) => ({
|
||||
color,
|
||||
title: colorTitle(color, colorLabels),
|
||||
items: highlights.filter((h) => h.color === color).sort(order).map((h) => ({ ref: `${h.bookName} ${h.chapter}:${h.verse}`, text: (h.text ?? '').trim(), translation: h.translation ?? '' })),
|
||||
}))
|
||||
.filter((g) => g.items.length > 0);
|
||||
const marks = bookmarks.slice().sort(order).map((b) => ({ ref: `${b.bookName} ${b.chapter}:${b.verse}`, color: COLOR_NAMES[b.color] ?? '' }));
|
||||
return { groups, bookmarks: marks };
|
||||
}
|
||||
|
||||
export function marksToMarkdown(model, { title = 'My highlights and bookmarks', date = new Date() } = {}) {
|
||||
const out = [`# ${title}`, '', `*${date.toLocaleDateString()}*`, ''];
|
||||
for (const g of model.groups) {
|
||||
out.push(`## ${g.title}`, '');
|
||||
for (const item of g.items) out.push(`- **${item.ref}**${item.translation ? ` (${item.translation})` : ''}${item.text ? ` — “${item.text.replace(/\s+/g, ' ')}”` : ''}`);
|
||||
out.push('');
|
||||
}
|
||||
if (model.bookmarks.length) {
|
||||
out.push('## Bookmarks', '');
|
||||
for (const b of model.bookmarks) out.push(`- ${b.ref}${b.color ? ` (${b.color.toLowerCase()})` : ''}`);
|
||||
out.push('');
|
||||
}
|
||||
if (!model.groups.length && !model.bookmarks.length) out.push('Nothing marked yet.', '');
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
export async function buildMarksDocxBlob(model, { title = 'My highlights and bookmarks', date = new Date() } = {}) {
|
||||
const { Document, HeadingLevel, Packer, Paragraph, TextRun } = await import('docx'); // the Word library only loads when used
|
||||
const heading = (text) => new Paragraph({ text, heading: HeadingLevel.HEADING_2, spacing: { before: 280, after: 100 } });
|
||||
const children = [
|
||||
new Paragraph({ text: title, heading: HeadingLevel.TITLE }),
|
||||
new Paragraph({ children: [new TextRun({ text: date.toLocaleDateString(), color: '7A7060', size: 21 })], spacing: { after: 160 } }),
|
||||
];
|
||||
for (const g of model.groups) {
|
||||
children.push(heading(g.title));
|
||||
for (const item of g.items) {
|
||||
children.push(new Paragraph({
|
||||
bullet: { level: 0 },
|
||||
children: [new TextRun({ text: item.ref, bold: true }), ...(item.translation ? [new TextRun({ text: ` (${item.translation})`, color: '7A7060' })] : []), ...(item.text ? [new TextRun({ text: ` — “${item.text.replace(/\s+/g, ' ')}”` })] : [])],
|
||||
spacing: { after: 60 },
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (model.bookmarks.length) {
|
||||
children.push(heading('Bookmarks'));
|
||||
for (const b of model.bookmarks) children.push(new Paragraph({ bullet: { level: 0 }, children: [new TextRun({ text: b.ref }), ...(b.color ? [new TextRun({ text: ` (${b.color.toLowerCase()})`, color: '7A7060' })] : [])], spacing: { after: 40 } }));
|
||||
}
|
||||
return Packer.toBlob(new Document({ sections: [{ children }] }));
|
||||
}
|
||||
|
||||
export function downloadMarksFile(blob, fileName) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = fileName;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { buildMarksDocxBlob, buildMarksModel, marksToMarkdown } from './marksExport.js';
|
||||
|
||||
const hl = (id, color, bookIndex, bookName, chapter, verse, text, extra = {}) => ({ id, color, bookIndex, bookName, chapter, verse, text, startOffset: 0, ...extra });
|
||||
const highlights = [
|
||||
hl('1', '#bbf7d0', 43, 'John', 3, 16, 'For God so loved the world', { translation: 'BSB' }),
|
||||
hl('2', '#bbf7d0', 44, 'Acts', 1, 8, 'you will receive power'),
|
||||
hl('3', '#fef08a', 44, 'Romans', 8, 28, 'all things work together'),
|
||||
hl('4', '#bbf7d0', 42, 'Luke', 1, 37, 'nothing is impossible with God'),
|
||||
];
|
||||
const bookmarks = [{ bookName: 'Psalms', bookIndex: 18, chapter: 23, verse: 1, color: '#bfdbfe' }, { bookName: 'Genesis', bookIndex: 0, chapter: 1, verse: 1, color: '#fef08a' }];
|
||||
|
||||
test('highlights are grouped by colour under the names you gave them, in Bible order', () => {
|
||||
const model = buildMarksModel({ highlights, bookmarks, colorLabels: { '#bbf7d0': 'Promises' } });
|
||||
expect(model.groups.map((g) => g.title)).toEqual(['Yellow', 'Promises (green)']); // yellow comes first in the picker order
|
||||
expect(model.groups[1].items.map((i) => i.ref)).toEqual(['Luke 1:37', 'John 3:16', 'Acts 1:8']);
|
||||
expect(model.bookmarks.map((b) => b.ref)).toEqual(['Genesis 1:1', 'Psalms 23:1']);
|
||||
});
|
||||
|
||||
test('the Markdown lists each mark with its words and the translation', () => {
|
||||
const md = marksToMarkdown(buildMarksModel({ highlights, bookmarks, colorLabels: {} }), { title: 'Mine', date: new Date(2026, 9, 6) });
|
||||
expect(md).toContain('# Mine');
|
||||
expect(md).toContain('## Green');
|
||||
expect(md).toContain('- **John 3:16** (BSB) — “For God so loved the world”');
|
||||
expect(md).toContain('## Bookmarks');
|
||||
expect(md).toContain('- Psalms 23:1 (blue)');
|
||||
expect(marksToMarkdown({ groups: [], bookmarks: [] })).toContain('Nothing marked yet.');
|
||||
});
|
||||
|
||||
test('the Word file is made', async () => {
|
||||
const blob = await buildMarksDocxBlob(buildMarksModel({ highlights, bookmarks, colorLabels: {} }));
|
||||
expect(blob.size).toBeGreaterThan(1000);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// Helpers for present mode: a clock, and how the talk is going against the minutes planned in the outline.
|
||||
import { flatten } from './outline.js';
|
||||
|
||||
export function formatClock(totalSeconds) {
|
||||
const s = Math.max(0, Math.round(totalSeconds));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
const mm = String(m).padStart(h ? 2 : 1, '0');
|
||||
return `${h ? `${h}:` : ''}${mm}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// The outline as a list of steps to move through, with the minutes planned so far at the *end* of each step.
|
||||
export function outlineSteps(tree) {
|
||||
let planned = 0;
|
||||
return flatten(tree).map(({ node, depth, label }) => {
|
||||
const minutes = Number.isFinite(node.minutes) && node.minutes > 0 ? node.minutes : 0;
|
||||
planned += minutes;
|
||||
return { id: node.id, label, depth, text: node.text ?? '', ref: node.ref ?? '', note: node.note ?? '', kind: node.kind ?? '', scripture: node.scripture ?? null, attachments: node.attachments ?? [], minutes, plannedEnd: planned };
|
||||
});
|
||||
}
|
||||
|
||||
// How the talk is going. Each point has a window in the plan: it should start when the earlier points' minutes are
|
||||
// used up and end when its own are. Being on a point before its window starts means you are ahead; still on it
|
||||
// after its window ends means you are behind; anywhere in the window is on pace.
|
||||
// -> { plannedSeconds (when it should start), endSeconds, totalSeconds, diffSeconds (+ ahead, - behind) } or null when no minutes are planned.
|
||||
export function pacing(steps, index, elapsedSeconds) {
|
||||
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
|
||||
if (!total) return null;
|
||||
const startSeconds = (index > 0 ? steps[index - 1].plannedEnd : 0) * 60;
|
||||
const endSeconds = (steps[index]?.plannedEnd ?? total) * 60;
|
||||
let diff = 0;
|
||||
if (elapsedSeconds < startSeconds) diff = startSeconds - elapsedSeconds;
|
||||
else if (elapsedSeconds > endSeconds) diff = endSeconds - elapsedSeconds;
|
||||
return { plannedSeconds: startSeconds, endSeconds, totalSeconds: total * 60, diffSeconds: diff };
|
||||
}
|
||||
|
||||
export const paceText = (p) => {
|
||||
if (!p) return '';
|
||||
const d = Math.round(p.diffSeconds);
|
||||
if (Math.abs(d) < 20) return 'On pace';
|
||||
return d > 0 ? `${formatClock(d)} ahead` : `${formatClock(-d)} behind`;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { formatClock, outlineSteps, paceText, pacing } from './present.js';
|
||||
|
||||
const n = (id, text, minutes = null, children = []) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children });
|
||||
const tree = [n('a', 'Intro', 5, [n('a1', 'Hook', 2)]), n('b', 'Body', 15), n('c', 'Close', 5)];
|
||||
|
||||
test('the clock reads like a clock', () => {
|
||||
expect(formatClock(0)).toBe('0:00');
|
||||
expect(formatClock(65)).toBe('1:05');
|
||||
expect(formatClock(3725)).toBe('1:02:05');
|
||||
expect(formatClock(-4)).toBe('0:00');
|
||||
});
|
||||
|
||||
test('the outline becomes steps in reading order with minutes planned so far', () => {
|
||||
const steps = outlineSteps(tree);
|
||||
expect(steps.map((s) => `${s.label}${s.text}`)).toEqual(['I.Intro', 'A.Hook', 'II.Body', 'III.Close']);
|
||||
expect(steps.map((s) => s.plannedEnd)).toEqual([5, 7, 22, 27]);
|
||||
});
|
||||
|
||||
test('pace: ahead before a point\'s window, behind after it, on pace inside it', () => {
|
||||
const steps = outlineSteps(tree); // Body is planned from 7:00 to 22:00
|
||||
expect(pacing(steps, 0, 0)).toMatchObject({ plannedSeconds: 0, totalSeconds: 27 * 60 });
|
||||
expect(pacing(steps, 2, 6 * 60).diffSeconds).toBe(60);
|
||||
expect(paceText(pacing(steps, 2, 6 * 60))).toBe('1:00 ahead');
|
||||
expect(paceText(pacing(steps, 2, 9 * 60))).toBe('On pace'); // part-way through Body
|
||||
expect(paceText(pacing(steps, 2, 25 * 60))).toBe('3:00 behind'); // still on Body after it should have ended
|
||||
expect(paceText(pacing(steps, 0, 65))).toBe('On pace'); // a minute into a 5-minute intro
|
||||
expect(pacing(outlineSteps([n('x', 'No minutes')]), 0, 10)).toBeNull();
|
||||
expect(paceText(null)).toBe('');
|
||||
});
|
||||
@@ -93,6 +93,7 @@ export function saveProjectToStorage(project) {
|
||||
title: updated.title,
|
||||
lastEdited: updated.lastEdited,
|
||||
chapterSummary: buildChapterSummary(updated),
|
||||
series: (updated.series ?? '').trim(),
|
||||
tags,
|
||||
};
|
||||
if (existing >= 0) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// What has been read in the Reader: { chapters: { "JHN.3": { n: times read, last: "2026-10-06" } }, days: ["2026-10-06", ...] }.
|
||||
// Pure helpers for recording it, merging two copies, and turning it into the numbers and pictures on the progress map.
|
||||
import { bookOptions, CHAPTER_COUNTS } from './bibleUtils.jsx';
|
||||
|
||||
export const emptyHistory = () => ({ chapters: {}, days: [] });
|
||||
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
export const chapterKey = (book, chapter) => `${book}.${chapter}`;
|
||||
|
||||
export function normalizeHistory(raw) {
|
||||
const chapters = {};
|
||||
if (raw?.chapters && typeof raw.chapters === 'object' && !Array.isArray(raw.chapters)) {
|
||||
for (const [k, v] of Object.entries(raw.chapters)) {
|
||||
if (/^[1-3A-Z]{3}\.\d{1,3}$/.test(k) && v && typeof v === 'object') chapters[k] = { n: Number.isInteger(v.n) && v.n > 0 ? v.n : 1, last: DATE.test(v.last ?? '') ? v.last : '' };
|
||||
}
|
||||
}
|
||||
const days = Array.isArray(raw?.days) ? [...new Set(raw.days.filter((d) => typeof d === 'string' && DATE.test(d)))].sort() : [];
|
||||
return { chapters, days };
|
||||
}
|
||||
|
||||
export function mergeHistory(a, b) {
|
||||
const x = normalizeHistory(a);
|
||||
const y = normalizeHistory(b);
|
||||
const chapters = { ...x.chapters };
|
||||
for (const [k, v] of Object.entries(y.chapters)) {
|
||||
const mine = chapters[k];
|
||||
chapters[k] = mine ? { n: Math.max(mine.n, v.n), last: mine.last > v.last ? mine.last : v.last } : v;
|
||||
}
|
||||
return { chapters, days: [...new Set([...x.days, ...y.days])].sort() };
|
||||
}
|
||||
|
||||
export const historyEqual = (a, b) => JSON.stringify(normalizeHistory(a)) === JSON.stringify(normalizeHistory(b));
|
||||
|
||||
// A chapter read on `date`: counted once per day however long you stay, and the day is marked as a reading day.
|
||||
export function recordRead(history, book, chapter, date) {
|
||||
const key = chapterKey(book, chapter);
|
||||
const was = history.chapters[key];
|
||||
const alreadyToday = was?.last === date;
|
||||
const chapters = alreadyToday ? history.chapters : { ...history.chapters, [key]: { n: (was?.n ?? 0) + 1, last: date } };
|
||||
const days = history.days.includes(date) ? history.days : [...history.days, date].sort();
|
||||
return chapters === history.chapters && days === history.days ? history : { chapters, days };
|
||||
}
|
||||
|
||||
// Chapters finished in reading plans count as read too: -> Set of "JHN.3" keys
|
||||
export function planChapterKeys(planProgress, getSchedule) {
|
||||
const keys = new Set();
|
||||
for (const p of planProgress ?? []) {
|
||||
for (const list of Object.values(p.chaptersRead ?? {})) for (const k of list ?? []) keys.add(String(k).toUpperCase());
|
||||
let schedule;
|
||||
try { schedule = getSchedule(p.planId) ?? []; } catch { schedule = []; }
|
||||
for (const day of p.completedDays ?? []) for (const r of schedule[day] ?? []) if (r?.book && r?.chapter) keys.add(chapterKey(r.book, r.chapter));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// -> { books: [{ abbrev, name, read, total, pct, testament }], read, total, pct, ot: {read,total}, nt: {read,total} }
|
||||
export function progressSummary(history, extraKeys = new Set()) {
|
||||
const have = new Set([...Object.keys(history.chapters), ...extraKeys]);
|
||||
const books = bookOptions.map((b, i) => {
|
||||
const total = CHAPTER_COUNTS[b.abbrev] ?? 0;
|
||||
let read = 0;
|
||||
for (let c = 1; c <= total; c += 1) if (have.has(chapterKey(b.abbrev, c))) read += 1;
|
||||
return { abbrev: b.abbrev, name: b.name, read, total, pct: total ? read / total : 0, testament: i < 39 ? 'ot' : 'nt' };
|
||||
});
|
||||
const sum = (list) => list.reduce((acc, b) => ({ read: acc.read + b.read, total: acc.total + b.total }), { read: 0, total: 0 });
|
||||
const all = sum(books);
|
||||
return { books, read: all.read, total: all.total, pct: all.total ? all.read / all.total : 0, ot: sum(books.filter((b) => b.testament === 'ot')), nt: sum(books.filter((b) => b.testament === 'nt')) };
|
||||
}
|
||||
|
||||
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
// The last `weeks` weeks as columns of 7 days (Sunday first), ending with the week of `today`.
|
||||
// -> [[{ date, on, future }]]
|
||||
export function heatmapWeeks(daySet, today = new Date(), weeks = 26) {
|
||||
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const startOfThisWeek = new Date(end.getFullYear(), end.getMonth(), end.getDate() - end.getDay());
|
||||
return Array.from({ length: weeks }, (_, w) => Array.from({ length: 7 }, (__, d) => {
|
||||
const date = new Date(startOfThisWeek.getFullYear(), startOfThisWeek.getMonth(), startOfThisWeek.getDate() - (weeks - 1 - w) * 7 + d);
|
||||
return { date: iso(date), on: daySet.has(iso(date)), future: date > end };
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { emptyHistory, heatmapWeeks, historyEqual, mergeHistory, normalizeHistory, planChapterKeys, progressSummary, recordRead } from './readingHistory.js';
|
||||
|
||||
test('a chapter is counted once a day, and the day is marked', () => {
|
||||
let h = recordRead(emptyHistory(), 'JHN', 3, '2026-10-05');
|
||||
expect(h).toEqual({ chapters: { 'JHN.3': { n: 1, last: '2026-10-05' } }, days: ['2026-10-05'] });
|
||||
const same = recordRead(h, 'JHN', 3, '2026-10-05');
|
||||
expect(same).toBe(h); // staying on the page changes nothing
|
||||
h = recordRead(h, 'JHN', 3, '2026-10-06');
|
||||
expect(h.chapters['JHN.3']).toEqual({ n: 2, last: '2026-10-06' });
|
||||
h = recordRead(h, 'ROM', 8, '2026-10-06');
|
||||
expect(h.days).toEqual(['2026-10-05', '2026-10-06']);
|
||||
});
|
||||
|
||||
test('two copies merge without losing anything', () => {
|
||||
const a = { chapters: { 'JHN.3': { n: 3, last: '2026-10-01' } }, days: ['2026-10-01'] };
|
||||
const b = { chapters: { 'JHN.3': { n: 1, last: '2026-10-09' }, 'GEN.1': { n: 1, last: '2026-10-02' } }, days: ['2026-10-02'] };
|
||||
const m = mergeHistory(a, b);
|
||||
expect(m.chapters['JHN.3']).toEqual({ n: 3, last: '2026-10-09' });
|
||||
expect(m.days).toEqual(['2026-10-01', '2026-10-02']);
|
||||
expect(historyEqual(m, mergeHistory(b, a))).toBe(true);
|
||||
expect(normalizeHistory({ chapters: { bad: {} }, days: ['x'] })).toEqual(emptyHistory());
|
||||
});
|
||||
|
||||
test('progress is counted per book and for the whole Bible, with plan chapters included', () => {
|
||||
const h = { chapters: { 'GEN.1': { n: 1, last: '' }, 'GEN.2': { n: 1, last: '' }, 'JHN.3': { n: 1, last: '' } }, days: [] };
|
||||
const sum = progressSummary(h, new Set(['GEN.3', 'JHN.3', 'REV.22']));
|
||||
const gen = sum.books.find((b) => b.abbrev === 'GEN');
|
||||
expect(gen).toMatchObject({ read: 3, total: 50, testament: 'ot' });
|
||||
expect(sum.books.find((b) => b.abbrev === 'JHN')).toMatchObject({ read: 1, total: 21, testament: 'nt' });
|
||||
expect(sum.books).toHaveLength(66);
|
||||
expect(sum.total).toBe(1189);
|
||||
expect(sum.read).toBe(5); // JHN.3 is in both and counts once
|
||||
expect(sum.ot.total + sum.nt.total).toBe(1189);
|
||||
expect(sum.nt.total).toBe(260);
|
||||
});
|
||||
|
||||
test('chapters finished in reading plans come from ticked chapters and completed days', () => {
|
||||
const schedule = { x: [[{ book: 'JHN', chapter: 1 }, { book: 'JHN', chapter: 2 }], [{ book: 'JHN', chapter: 3 }]] };
|
||||
const keys = planChapterKeys([{ planId: 'x', completedDays: [0], chaptersRead: { 1: ['JHN.3'] } }], (id) => schedule[id]);
|
||||
expect([...keys].sort()).toEqual(['JHN.1', 'JHN.2', 'JHN.3']);
|
||||
expect(planChapterKeys([{ planId: 'gone', completedDays: [0] }], () => { throw new Error('unknown plan'); }).size).toBe(0);
|
||||
});
|
||||
|
||||
test('the calendar is 26 weeks of 7 days ending this week, marking the days read', () => {
|
||||
const weeks = heatmapWeeks(new Set(['2026-10-06', '2026-10-01']), new Date(2026, 9, 6), 26); // Tuesday
|
||||
expect(weeks).toHaveLength(26);
|
||||
expect(weeks.every((w) => w.length === 7)).toBe(true);
|
||||
const last = weeks.at(-1);
|
||||
expect(last[0].date).toBe('2026-10-04'); // that week's Sunday
|
||||
expect(last[2]).toMatchObject({ date: '2026-10-06', on: true, future: false });
|
||||
expect(last[3].future).toBe(true);
|
||||
expect(weeks.flat().filter((d) => d.on).map((d) => d.date).sort()).toEqual(['2026-10-01', '2026-10-06']);
|
||||
expect(weeks[0][0].date).toBe('2026-04-12'); // 25 weeks before
|
||||
});
|
||||
@@ -11,15 +11,35 @@ export function tagCounts(index) {
|
||||
return [...counts].map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||
}
|
||||
|
||||
export function filterStudies(index, { query = '', tags = [] } = {}) {
|
||||
// The series names in use, A to Z, with how many studies each has.
|
||||
export function seriesList(index) {
|
||||
const counts = new Map();
|
||||
for (const e of index) { const name = (e.series ?? '').trim(); if (name) counts.set(name, (counts.get(name) ?? 0) + 1); }
|
||||
return [...counts].map(([name, count]) => ({ name, count })).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Splits studies into one group per series (A to Z), with the ones in no series last. Keeps their order within a group.
|
||||
export function groupBySeries(entries) {
|
||||
const groups = new Map();
|
||||
for (const e of entries) {
|
||||
const name = (e.series ?? '').trim();
|
||||
if (!groups.has(name)) groups.set(name, []);
|
||||
groups.get(name).push(e);
|
||||
}
|
||||
const named = [...groups].filter(([n]) => n).sort(([a], [b]) => a.localeCompare(b)).map(([name, items]) => ({ name, items }));
|
||||
return groups.has('') ? [...named, { name: '', items: groups.get('') }] : named;
|
||||
}
|
||||
|
||||
export function filterStudies(index, { query = '', tags = [], series = '' } = {}) {
|
||||
const raw = String(query).trim().toLowerCase();
|
||||
const tagOnly = raw.startsWith('#');
|
||||
const q = tagOnly ? raw.slice(1).trim() : raw;
|
||||
return index.filter((entry) => {
|
||||
if (series === '__none') { if ((entry.series ?? '').trim()) return false; } else if (series && (entry.series ?? '').trim() !== series) return false;
|
||||
const own = (entry.tags ?? []).map((t) => t.toLowerCase());
|
||||
if (tags.length && !tags.every((t) => own.includes(t.toLowerCase()))) return false;
|
||||
if (!q) return true;
|
||||
if (own.some((t) => t.includes(q))) return true;
|
||||
return !tagOnly && ((entry.title ?? '').toLowerCase().includes(q) || (entry.chapterSummary ?? '').toLowerCase().includes(q));
|
||||
return !tagOnly && ((entry.title ?? '').toLowerCase().includes(q) || (entry.chapterSummary ?? '').toLowerCase().includes(q) || (entry.series ?? '').toLowerCase().includes(q));
|
||||
});
|
||||
}
|
||||
@@ -33,3 +33,22 @@ test('chosen tags must all be present, and combine with the search', () => {
|
||||
test('tags are counted, most used first', () => {
|
||||
expect(tagCounts(index)).toEqual([{ tag: 'hope', count: 2 }, { tag: 'grace', count: 1 }, { tag: 'leadership', count: 1 }]);
|
||||
});
|
||||
|
||||
import { groupBySeries, seriesList } from './studyFilter.js';
|
||||
|
||||
test('series are listed and studies grouped under them, with unsorted ones last', () => {
|
||||
const idx = [
|
||||
{ id: 'a', title: 'A', series: 'Romans' }, { id: 'b', title: 'B', series: '' },
|
||||
{ id: 'c', title: 'C', series: 'Advent' }, { id: 'd', title: 'D', series: 'Romans' },
|
||||
];
|
||||
expect(seriesList(idx)).toEqual([{ name: 'Advent', count: 1 }, { name: 'Romans', count: 2 }]);
|
||||
expect(groupBySeries(idx).map((g) => `${g.name || '(none)'}:${g.items.map((e) => e.id).join('')}`)).toEqual(['Advent:c', 'Romans:ad', '(none):b']);
|
||||
expect(groupBySeries([{ id: 'x' }]).map((g) => g.name)).toEqual(['']);
|
||||
});
|
||||
|
||||
test('a series can be chosen as a filter, and the search finds studies by series name', () => {
|
||||
const idx = [{ id: 'a', title: 'Ch 8', series: 'Romans' }, { id: 'b', title: 'Ch 1' }, { id: 'c', title: 'Gen', series: 'Advent' }];
|
||||
expect(filterStudies(idx, { series: 'Romans' }).map((e) => e.id)).toEqual(['a']);
|
||||
expect(filterStudies(idx, { series: '__none' }).map((e) => e.id)).toEqual(['b']);
|
||||
expect(filterStudies(idx, { query: 'advent' }).map((e) => e.id)).toEqual(['c']);
|
||||
});
|
||||
@@ -38,13 +38,36 @@ export const STUDY_TEMPLATES = [
|
||||
},
|
||||
];
|
||||
|
||||
export const getTemplate = (id) => STUDY_TEMPLATES.find((t) => t.id === id) ?? STUDY_TEMPLATES[0];
|
||||
export const getTemplate = (id, userTemplates = []) => STUDY_TEMPLATES.find((t) => t.id === id) ?? userTemplates.find((t) => t.id === id) ?? STUDY_TEMPLATES[0];
|
||||
|
||||
// The prompt for one note box in a study built from `templateId`, or undefined for the default one.
|
||||
export const templatePrompt = (templateId, field) => getTemplate(templateId).prompts?.[field];
|
||||
// A template can be given as an id of a built-in, or as the template itself (a study keeps a copy of the one
|
||||
// it was made from, so it still works for a co-teacher or on another device that doesn't have it).
|
||||
const asTemplate = (t) => (t && typeof t === 'object' ? t : getTemplate(t));
|
||||
|
||||
// The prompt for one note box in a study built from this template, or undefined for the default one.
|
||||
export const templatePrompt = (template, field) => asTemplate(template).prompts?.[field];
|
||||
|
||||
export const STARTER_FIELDS = ['generalNotes', 'observation', 'interpretation', 'application'];
|
||||
|
||||
// Fields a new chunk starts with under this template.
|
||||
export const templateStarter = (templateId) => {
|
||||
const starter = getTemplate(templateId).starter;
|
||||
return starter ? { generalNotes: starter } : {};
|
||||
export const templateStarter = (template) => {
|
||||
const t = asTemplate(template);
|
||||
const starters = { ...(t.starter ? { generalNotes: t.starter } : {}), ...(t.starters ?? {}) };
|
||||
return Object.fromEntries(STARTER_FIELDS.filter((f) => typeof starters[f] === 'string' && starters[f].trim()).map((f) => [f, starters[f]]));
|
||||
};
|
||||
|
||||
// A template made from the notes written in a chunk: what it asks (the study's current prompts) and the scaffold
|
||||
// text in each note box. Returns null when there is nothing in the chunk to keep.
|
||||
export function templateFromChunk({ name, chunk, template }) {
|
||||
const starters = Object.fromEntries(STARTER_FIELDS.filter((f) => (chunk[f] ?? '').trim()).map((f) => [f, chunk[f].slice(0, 2000)]));
|
||||
const t = asTemplate(template);
|
||||
const prompts = Object.fromEntries(['observation', 'interpretation', 'application'].filter((f) => t.prompts?.[f]).map((f) => [f, t.prompts[f]]));
|
||||
if (!name.trim() || (!Object.keys(starters).length && !Object.keys(prompts).length)) return null;
|
||||
return {
|
||||
id: `user-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
||||
name: name.trim().slice(0, 40),
|
||||
description: 'Your own template',
|
||||
...(Object.keys(prompts).length ? { prompts } : {}),
|
||||
...(Object.keys(starters).length ? { starters } : {}),
|
||||
};
|
||||
}
|
||||
@@ -16,3 +16,28 @@ test('templates change the prompts and may pre-fill background notes', () => {
|
||||
expect(blankChunk(1, 5).generalNotes).toBe('');
|
||||
expect(blankChunk(1, 5, null, 'devotional').generalNotes).toBe('');
|
||||
});
|
||||
|
||||
import { templateFromChunk } from './studyTemplates.js';
|
||||
|
||||
test('a template of your own can be made from what is written in a chunk', () => {
|
||||
const chunk = { observation: 'Who: \nWhat: ', interpretation: '', application: '', generalNotes: 'Context:\n' };
|
||||
const made = templateFromChunk({ name: ' My lesson ', chunk, template: 'inductive' });
|
||||
expect(made.id).toMatch(/^user-/);
|
||||
expect(made.name).toBe('My lesson');
|
||||
expect(made.starters).toEqual({ observation: 'Who: \nWhat: ', generalNotes: 'Context:\n' }); // empty boxes are left out
|
||||
expect(made.prompts.observation).toMatch(/Look before you leap/); // keeps the prompts of the template it came from
|
||||
expect(templateFromChunk({ name: '', chunk, template: 'blank' })).toBeNull();
|
||||
expect(templateFromChunk({ name: 'x', chunk: { observation: '', interpretation: '', application: '', generalNotes: '' }, template: 'blank' })).toBeNull();
|
||||
});
|
||||
|
||||
test('new chunks start from your template\'s text in every box it filled', () => {
|
||||
const def = { id: 'user-1', name: 'Mine', starters: { observation: 'Who:', generalNotes: 'Context:' }, prompts: { application: 'So what?' } };
|
||||
const c = blankChunk(1, 3, null, def);
|
||||
expect(c.observation).toBe('Who:');
|
||||
expect(c.generalNotes).toBe('Context:');
|
||||
expect(c.interpretation).toBe('');
|
||||
expect(templatePrompt(def, 'application')).toBe('So what?');
|
||||
expect(templatePrompt(def, 'observation')).toBeUndefined();
|
||||
expect(getTemplate('user-1', [def]).name).toBe('Mine');
|
||||
expect(getTemplate('user-9', [def]).id).toBe('blank');
|
||||
});
|
||||
Reference in new issue
Block a user