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>
80 lines
4.4 KiB
JavaScript
80 lines
4.4 KiB
JavaScript
// 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 };
|
|
}));
|
|
}
|