Add Insights, Observe-first, compare, handout layouts, richer search, backup nudge

- Commentary and study aids wait for 15 words of your own Observe notes.
- Insights page: this week's own work, themes across studies, words you keep
  studying, what slipped in recalls, open follow-ups.
- Recall can record what slipped; session notes (teach it back, who came,
  group notes, follow-ups); "Pray about this" from Apply.
- Compare any two passages side by side.
- Handout layouts: standard, questions only, large print, note-taking.
- Search understands "in <book>", "last week/month/year" and a year.
- Home shows the last backup age and nudges after two weeks.
- Phone: the stage bar scrolls instead of clipping Script and Export.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 12:42:11 -04:00
1 parent 2ddc4e1ff6
commit d8e522dd06
24 files changed
+540 -28

No files matched your search

+13
View File
@@ -14,6 +14,7 @@ import {
formatRelativeDate,
loadProjectIndex,
} from '../utils/bibleUtils.jsx';
import { backupAge, lastBackupAt } from '../utils/backup.js';
import { loadProjectById } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
@@ -38,6 +39,8 @@ export default function HomePage() {
audioState,
audioProgress,
openImportProject,
setCurrentPage,
openBackup,
openNewProject,
pullLatestFromServer,
resumeProject,
@@ -130,6 +133,7 @@ export default function HomePage() {
active="studies"
actions={(
<>
<button type="button" onClick={() => setCurrentPage('insights')} className={linkBtn} title="This week, themes, words, what slipped, follow-ups">Insights</button>
<button type="button" onClick={openImportProject} className={linkBtn} title="Set up a whole series from a session list (.docx)">Import Session List</button>
<button type="button" onClick={openNewProject} className="rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ New Project</button>
</>
@@ -137,6 +141,15 @@ export default function HomePage() {
>
<main className="mx-auto grid max-w-6xl gap-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
<section className="min-w-0">
{projectIndex.length > 0 && (() => {
const age = backupAge(lastBackupAt());
return (
<p className={`mb-3 flex flex-wrap items-center gap-2 px-1 text-xs ${age.stale ? 'text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>
{age.text}.
<button type="button" onClick={openBackup} className="text-[var(--r-accent)] hover:underline">Back up now</button>
</p>
);
})()}
<WeeklyRecall />
<RecallHistory />
<IqDailyCard />
+107
View File
@@ -0,0 +1,107 @@
// Insights: your studies seen together — this week's own work, themes (tags), words you keep studying,
// what slipped in recalls, and follow-ups still open.
import { useMemo } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
import { loadProjectById } from '../utils/projectStorage.js';
import { followUpList, slippedList, themeIndex, weeklySummary, wordIndex } from '../utils/insights.js';
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5';
const open = 'text-xs text-[var(--r-accent)] hover:underline';
function Stat({ label, value }) {
return (
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-4 py-3">
<p className="text-2xl font-medium tabular-nums">{value}</p>
<p className="text-xs text-[var(--r-sub)]">{label}</p>
</div>
);
}
export default function InsightsPage() {
const { projectIndex, openStudyChunk, goHome } = useApp();
const data = useMemo(() => {
const projects = projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean);
return { week: weeklySummary(projects), themes: themeIndex(projects), words: wordIndex(projects), slipped: slippedList(projects), followUps: followUpList(projects) };
}, [projectIndex]);
const { week, themes, words, slipped, followUps } = data;
const link = (item) => <button type="button" onClick={() => openStudyChunk(item.projectId, item.chunkId)} className={open}>{item.reference} · {item.projectTitle}</button>;
return (
<PageShell title="Insights" active="studies" onBack={goHome} maxWidth="max-w-4xl">
<main className="mx-auto max-w-4xl space-y-5 px-4 pb-10">
<section aria-label="This week" className={card}>
<h2 className="text-sm font-medium">This week</h2>
<p className="mb-3 mt-0.5 text-xs text-[var(--r-sub)]">Counted over the chunks you edited in the last 7 days. Pasted text isn’t counted as yours.</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Chunks worked on" value={week.chunksTouched} />
<Stat label="Words of your own" value={week.ownWords} />
<Stat label="Your own words" value={week.ownShare === null ? '–' : `${week.ownShare}%`} />
<Stat label="Recalls done" value={week.recalls} />
</div>
{week.teachBacks > 0 && <p className="mt-2 text-xs text-[var(--r-sub)]">{week.teachBacks} teach-it-back summar{week.teachBacks === 1 ? 'y' : 'ies'} written.</p>}
</section>
<section aria-label="Open follow-ups" className={card}>
<h2 className="text-sm font-medium">Follow-ups</h2>
{followUps.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">None yet. Add them under “More” in the Study stage.</p> : (
<ul className="mt-2 space-y-1.5 text-sm">
{followUps.map((f) => (
<li key={f.id} className="flex flex-wrap items-baseline gap-x-3">
<span className={f.done ? 'text-[var(--r-sub)] line-through' : ''}>{f.text}</span>
{link(f)}
</li>
))}
</ul>
)}
</section>
<section aria-label="What slipped" className={card}>
<h2 className="text-sm font-medium">What slipped in recalls</h2>
{slipped.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">Nothing yet. After a weekly recall, note what you missed.</p> : (
<ul className="mt-2 space-y-3 text-sm">
{slipped.map((r) => (
<li key={`${r.chunkId}-${r.at}`}>
<p className="text-xs text-[var(--r-sub)]">{new Date(r.at).toLocaleDateString()} · {link(r)}</p>
<ul className="ml-4 list-disc">{r.items.map((i) => <li key={i}>{i}</li>)}</ul>
</li>
))}
</ul>
)}
</section>
<section aria-label="Themes" className={card}>
<h2 className="text-sm font-medium">Themes</h2>
{themes.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">Tag a chunk (under “More”) and it appears here with every other chunk on the same theme.</p> : (
<div className="mt-2 space-y-3">
{themes.map((t) => (
<details key={t.tag} className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2">
<summary className="cursor-pointer text-sm"><span className="font-medium">{t.tag}</span> <span className="text-xs text-[var(--r-sub)]">· {t.count} chunk{t.count === 1 ? '' : 's'}</span></summary>
<ul className="mt-2 space-y-2">
{t.items.map((i) => (
<li key={`${i.chunkId}`} className="text-sm">{link(i)}{i.snippet && <p className="font-serif text-[13px] text-[var(--r-sub)]">{i.snippet}</p>}</li>
))}
</ul>
</details>
))}
</div>
)}
</section>
<section aria-label="Words you keep studying" className={card}>
<h2 className="text-sm font-medium">Words you keep studying</h2>
{words.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">Words you study in two or more places show up here.</p> : (
<ul className="mt-2 space-y-2 text-sm">
{words.map((w) => (
<li key={w.key}>
<span className="font-serif text-base">{w.lexeme}</span> <span className="text-xs text-[var(--r-sub)]">{[w.transliteration, w.strong, w.gloss].filter(Boolean).join(' · ')} · {w.count} places</span>
<span className="mt-0.5 flex flex-wrap gap-x-3">{w.places.map((p) => <span key={p.chunkId}>{link(p)}</span>)}</span>
</li>
))}
</ul>
)}
</section>
</main>
</PageShell>
);
}
+7 -2
View File
@@ -13,10 +13,11 @@ import OutlineRail from '../components/study/OutlineRail.jsx';
import ScripturePane from '../components/study/ScripturePane.jsx';
import NotesPane from '../components/study/NotesPane.jsx';
import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.jsx';
import CompareSheet from '../components/study/CompareSheet.jsx';
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate } = useApp();
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
// Short-lived status line (word suggestions, lookups, …)
const [statusMessage, setStatusMessage] = useState('');
@@ -41,6 +42,7 @@ export default function StudyPage() {
const [tab, setTab] = useResourceTab();
const [openWordId, setOpenWordId] = useState(null);
const [comparing, setComparing] = useState(false);
useEffect(() => { setOpenWordId(null); }, [selectedChunk?.id]);
const resources = useStudyResources({
@@ -103,6 +105,7 @@ export default function StudyPage() {
)}
</div>
<div className="flex shrink-0 gap-1.5">
<button type="button" onClick={() => setComparing(true)} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5" title="Set another passage beside this one">Compare…</button>
<button type="button" onClick={goPrev} disabled={globalIndex <= 0} aria-label="Previous chunk"
className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-40">‹</button>
<button type="button" onClick={goNext} disabled={globalIndex >= allChunks.length - 1} aria-label="Next chunk"
@@ -132,7 +135,8 @@ export default function StudyPage() {
<section aria-label="Notes" className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[11]">
<h2 className="shrink-0 border-b border-[var(--r-line)] px-4 py-2.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</h2>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} />
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify}
onPray={() => startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
</div>
</section>
<section aria-label="Resources" className="flex min-h-[24rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[10]">
@@ -148,6 +152,7 @@ export default function StudyPage() {
</aside>
</div>
{comparing && selectedChunk && <CompareSheet currentChunkId={selectedChunk.id} onClose={() => setComparing(false)} />}
<SuggestWordsSheet wordStudy={wordStudy} />
{statusMessage && (
<div role="status" className="fixed bottom-6 left-1/2 z-[60] -translate-x-1/2 rounded-full bg-[var(--r-text)] px-4 py-2 text-sm text-[var(--r-bg)] shadow-lg">