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>
61 lines
3.9 KiB
React
61 lines
3.9 KiB
React
// "Your reading": how much of the Bible you have read (every chapter you spent time on in the Reader, plus the
|
||
// ones you finished in reading plans), a tile for each book that fills as you read it, and a calendar of the
|
||
// days you read in the last six months.
|
||
import { useMemo } from 'react';
|
||
import { getPlanSchedule } from '../utils/readingPlans.js';
|
||
import { heatmapWeeks, planChapterKeys, progressSummary } from '../utils/readingHistory.js';
|
||
|
||
const pct = (n) => `${Math.round(n * 100)}%`;
|
||
|
||
export default function ReadingProgress({ history, planProgress = [], activity = [], onOpenBook }) {
|
||
const summary = useMemo(() => progressSummary(history, planChapterKeys(planProgress, getPlanSchedule)), [history, planProgress]);
|
||
const days = useMemo(() => new Set([...(history.days ?? []), ...activity]), [history.days, activity]);
|
||
const weeks = useMemo(() => heatmapWeeks(days), [days]);
|
||
const daysInView = weeks.flat().filter((d) => d.on).length;
|
||
const group = (list) => (
|
||
<div className="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-1.5">
|
||
{list.map((b) => (
|
||
<button key={b.abbrev} type="button" onClick={() => onOpenBook?.(b.abbrev)} title={`${b.name}: ${b.read} of ${b.total} chapters read`}
|
||
aria-label={`${b.name}, ${b.read} of ${b.total} chapters read`}
|
||
className="relative overflow-hidden rounded-lg border border-[var(--r-line)] px-2 py-1.5 text-left text-xs hover:border-[var(--r-accent)]">
|
||
<span aria-hidden="true" className="absolute inset-y-0 left-0 bg-[var(--r-accent)] opacity-25" style={{ width: pct(b.pct) }} />
|
||
<span className="relative block truncate font-medium">{b.name}</span>
|
||
<span className="relative block text-[10px] text-[var(--r-sub)]">{b.read}/{b.total}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<section aria-label="Your reading" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||
<h2 className="text-sm font-medium">Your reading</h2>
|
||
<p className="text-xs text-[var(--r-sub)]">
|
||
<span className="text-base font-medium text-[var(--r-text)]">{pct(summary.pct)}</span> of the Bible · {summary.read} of {summary.total} chapters
|
||
</p>
|
||
</div>
|
||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[var(--r-line)]" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(summary.pct * 100)} aria-label="Bible read">
|
||
<div className="h-full bg-[var(--r-accent)]" style={{ width: pct(summary.pct) }} />
|
||
</div>
|
||
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Old Testament {summary.ot.read}/{summary.ot.total} · New Testament {summary.nt.read}/{summary.nt.total}. A chapter counts once you’ve had it open for a bit in the Reader, or finished it in a plan.</p>
|
||
|
||
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Old Testament</h3>
|
||
{group(summary.books.filter((b) => b.testament === 'ot'))}
|
||
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">New Testament</h3>
|
||
{group(summary.books.filter((b) => b.testament === 'nt'))}
|
||
|
||
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Days you read · {daysInView} in the last 6 months</h3>
|
||
<div className="flex gap-[3px] overflow-x-auto pb-1" role="img" aria-label={`${daysInView} days read in the last six months`}>
|
||
{weeks.map((week) => (
|
||
<div key={week[0].date} className="flex flex-col gap-[3px]">
|
||
{week.map((d) => (
|
||
<span key={d.date} title={d.future ? '' : `${d.date}${d.on ? ': read' : ''}`}
|
||
className={`h-3 w-3 rounded-[3px] ${d.future ? 'opacity-0' : d.on ? 'bg-[var(--r-accent)]' : 'bg-[var(--r-line)]'}`} />
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|