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

+79
View File
@@ -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 };
}));
}