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:
1 parent
2ddc4e1ff6
commit
d8e522dd06
24 files changed
+540
-28
No files matched your search
+3
-1
@@ -37,6 +37,7 @@ const ImportPage = lazy(() => import('./pages/ImportPage.jsx'));
|
||||
const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
|
||||
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
|
||||
const InsightsPage = lazy(() => import('./pages/InsightsPage.jsx'));
|
||||
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
|
||||
const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx'));
|
||||
const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx'));
|
||||
@@ -167,7 +168,7 @@ const AppInner = () => {
|
||||
useEffect(() => {
|
||||
const names = {
|
||||
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Export',
|
||||
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal',
|
||||
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal', insights: 'Insights',
|
||||
};
|
||||
let page = names[currentPage] ?? '';
|
||||
if (currentPage === 'reader') {
|
||||
@@ -629,6 +630,7 @@ function AppRouter() {
|
||||
if (currentPage === 'prayer') return <PrayerPage />;
|
||||
if (currentPage === 'reader') return <ReaderPage />;
|
||||
if (currentPage === 'import') return <ImportPage />;
|
||||
if (currentPage === 'insights') return <InsightsPage />;
|
||||
if (currentPage === 'study') return <StudyPage />;
|
||||
if (currentPage === 'outline') return <OutlinePage />;
|
||||
if (currentPage === 'script') return <ScriptPage />;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// Export → Lesson handout: a printable page for each session (or one session), for a group to follow along.
|
||||
import { useState } from 'react';
|
||||
import { printHandout, sessionsFor } from '../utils/handout.js';
|
||||
import { HANDOUT_LAYOUTS, printHandout, sessionsFor } from '../utils/handout.js';
|
||||
|
||||
export default function HandoutExport({ project }) {
|
||||
const sessions = sessionsFor(project);
|
||||
const [which, setWhich] = useState('all');
|
||||
const [lines, setLines] = useState(true);
|
||||
const [layout, setLayout] = useState('standard');
|
||||
const [blocked, setBlocked] = useState(false);
|
||||
if (sessions.length === 0) return null;
|
||||
const go = () => setBlocked(!printHandout(project, which === 'all' ? null : [which], { answerLines: lines }));
|
||||
const go = () => setBlocked(!printHandout(project, which === 'all' ? null : [which], { answerLines: lines, layout }));
|
||||
return (
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Lesson handout">
|
||||
<h2 className="text-sm font-medium">Lesson handout</h2>
|
||||
@@ -20,6 +21,11 @@ export default function HandoutExport({ project }) {
|
||||
{sessions.map((s) => <option key={s.chunk.id} value={s.chunk.id}>{s.number}. {s.reference}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-[var(--r-sub)]">Layout
|
||||
<select value={layout} onChange={(e) => setLayout(e.target.value)} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
|
||||
{HANDOUT_LAYOUTS.map((l) => <option key={l.id} value={l.id}>{l.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 pb-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={lines} onChange={(e) => setLines(e.target.checked)} className="accent-[var(--r-accent)]" /> Lines to write on</label>
|
||||
<button type="button" onClick={go} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Print / Save PDF</button>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function WeeklyRecall() {
|
||||
const { projectIndex, saveChunkRecall, openStudyChunk } = useApp();
|
||||
const [step, setStep] = useState('card'); // card -> write -> compare -> done
|
||||
const [text, setText] = useState('');
|
||||
const [gaps, setGaps] = useState('');
|
||||
const pick = useMemo(
|
||||
() => pickRecall(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)),
|
||||
[projectIndex],
|
||||
@@ -24,7 +25,7 @@ export default function WeeklyRecall() {
|
||||
if (!pick) return null;
|
||||
|
||||
const finish = (rating) => {
|
||||
saveChunkRecall(pick.projectId, pick.chunk.id, { at: Date.now(), text: text.trim(), rating });
|
||||
saveChunkRecall(pick.projectId, pick.chunk.id, { at: Date.now(), text: text.trim(), rating, gaps: gaps.trim() });
|
||||
setStep('done');
|
||||
};
|
||||
|
||||
@@ -63,6 +64,10 @@ export default function WeeklyRecall() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-3 block text-xs text-[var(--r-sub)]">What did you miss or get wrong? <span className="opacity-70">(one per line; collected in Insights)</span>
|
||||
<textarea aria-label="What slipped" value={gaps} onChange={(e) => setGaps(e.target.value)} rows={2}
|
||||
className="mt-1 w-full resize-y rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none" />
|
||||
</label>
|
||||
<p className="mt-3 text-xs text-[var(--r-sub)]">How did you do?</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{RATE.map((r) => (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useApp } from '../../context/AppContext.js';
|
||||
import { useToast } from '../Toasts.jsx';
|
||||
import { DownloadProgress } from '../OfflineSave.jsx';
|
||||
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
|
||||
import { backupFileName, buildBackup, restoreProjects, validateBackup } from '../../utils/backup.js';
|
||||
import { backupFileName, buildBackup, markBackedUp, restoreProjects, validateBackup } from '../../utils/backup.js';
|
||||
import { saveRemoteProject } from '../../syncService.js';
|
||||
|
||||
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
||||
@@ -38,6 +38,7 @@ export default function DataSection() {
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
markBackedUp();
|
||||
toast(`Backup downloaded: ${plural(backup.projects.length, 'study', 'studies')} and your reading marks`);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Compare two passages side by side — your notes and the verses — for parallels (the Gospels), repeated themes,
|
||||
// or the same chapter studied twice. Pick any chunk from any of your studies to set beside the one you're on.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext.js';
|
||||
import { loadProjectById } from '../../utils/projectStorage.js';
|
||||
import { formatChunkReference, getChunkVerseEntries } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
|
||||
|
||||
// Every chunk in your studies, as a pick-list entry that remembers where it sits.
|
||||
function choices(projectIndex, currentProject) {
|
||||
const projects = projectIndex.map((e) => (e.id === currentProject?.id ? currentProject : loadProjectById(e.id))).filter(Boolean);
|
||||
return projects.flatMap((project) => project.chapters.flatMap((chapter, chapterIndex) => chapter.chunks.map((chunk) => ({
|
||||
key: `${project.id}:${chunk.id}`, project, chapterIndex, chunk,
|
||||
label: `${formatChunkReference(project, chapterIndex, chunk, '–')} · ${project.title}`,
|
||||
}))));
|
||||
}
|
||||
|
||||
function Column({ entry }) {
|
||||
if (!entry) return <p className="p-4 text-sm text-[var(--r-sub)]">Pick a passage to compare.</p>;
|
||||
const verses = getChunkVerseEntries(entry.project, entry.chapterIndex, entry.chunk);
|
||||
return (
|
||||
<div className="min-w-0 space-y-3 p-4">
|
||||
<h3 className="font-medium">{entry.label}</h3>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded-xl bg-[var(--r-bar)] p-3 font-serif text-[13px] leading-relaxed">
|
||||
{verses.map((v) => <p key={v.number}><sup className="mr-1 text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}
|
||||
</div>
|
||||
{FIELDS.map(([f, label]) => (
|
||||
<div key={f}>
|
||||
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{label}</p>
|
||||
<p className="whitespace-pre-line font-serif text-[14px] leading-relaxed">{entry.chunk[f]?.trim() || <span className="text-[var(--r-sub)]">Nothing written.</span>}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompareSheet({ currentChunkId, onClose }) {
|
||||
const { projectIndex, project } = useApp();
|
||||
const all = useMemo(() => choices(projectIndex, project), [projectIndex, project]);
|
||||
const mine = all.find((c) => c.chunk.id === currentChunkId && c.project.id === project?.id);
|
||||
const [otherKey, setOtherKey] = useState('');
|
||||
const other = all.find((c) => c.key === otherKey);
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" aria-label="Compare passages" className="fixed inset-0 z-[70] flex flex-col bg-[var(--r-bg)] text-[var(--r-text)]">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-2.5">
|
||||
<h2 className="mr-auto text-sm font-medium sm:mr-0">Compare passages</h2>
|
||||
<select aria-label="Compare with" value={otherKey} onChange={(e) => setOtherKey(e.target.value)}
|
||||
className="order-last min-w-0 basis-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm sm:order-none sm:basis-0 sm:flex-1">
|
||||
<option value="">Choose a passage to set beside this one…</option>
|
||||
{all.filter((c) => c.key !== mine?.key).map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={onClose} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5">Close</button>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 overflow-y-auto md:grid-cols-2 md:divide-x md:divide-[var(--r-line)]">
|
||||
<Column entry={mine} />
|
||||
<Column entry={other} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
|
||||
// area for background notes and tags so the page stays quiet.
|
||||
import { useEffect, useState } from 'react';
|
||||
import SessionNotes from './SessionNotes.jsx';
|
||||
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
||||
import { ownShare, ownWords, pastedWords, withPaste, clearPastes } from '../../utils/ownWork.js';
|
||||
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
|
||||
@@ -17,7 +18,7 @@ const SECTIONS = [
|
||||
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
||||
|
||||
export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify }) {
|
||||
export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify, onPray }) {
|
||||
const [open, setOpen] = useState(() => {
|
||||
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
|
||||
});
|
||||
@@ -80,7 +81,10 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
||||
<button type="button" onClick={() => updateChunk(chunk.id, { aiPastes: clearPastes(chunk, field) })} className="text-[var(--r-accent)] hover:underline">That’s mine</button>
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
|
||||
{field === 'application' && onPray && (
|
||||
<button type="button" onClick={onPray} className="mt-1.5 mr-3 text-xs text-[var(--r-accent)] hover:underline">Pray about this →</button>
|
||||
)}
|
||||
<div className="mt-1.5 inline-block"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -106,6 +110,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
||||
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
|
||||
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
|
||||
</label>
|
||||
<SessionNotes chunk={chunk} updateChunk={updateChunk} />
|
||||
{saveTemplate && (
|
||||
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
|
||||
{!tplOpen ? (
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Right-hand pane of the Study stage: commentary, word study, cross-references and
|
||||
// people & places, all driven by the selected chunk.
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import CommentaryChunkTab from './CommentaryChunkTab.jsx';
|
||||
import WordStudyTab from './WordStudyTab.jsx';
|
||||
import CrossRefsTab from './CrossRefsTab.jsx';
|
||||
import PeopleChunkTab from './PeopleChunkTab.jsx';
|
||||
import IqChunkAids from '../iq/IqChunkAids.jsx';
|
||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||
import { OBSERVE_FIRST_MIN, observeFirstDone, ownWords } from '../../utils/ownWork.js';
|
||||
|
||||
export const RESOURCE_TABS = [
|
||||
{ id: 'commentary', label: 'Commentary' },
|
||||
@@ -20,6 +21,10 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
||||
const iq = useIqAvailable();
|
||||
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
|
||||
if (tab === 'aids' && !iq) tab = 'commentary';
|
||||
// Commentary-type tabs wait for your own Observe notes (per chunk, until you choose to look anyway).
|
||||
const [unlocked, setUnlocked] = useState(() => new Set());
|
||||
useEffect(() => { setUnlocked(new Set()); }, [chunk.id]);
|
||||
const held = (tab === 'commentary' || tab === 'aids') && !observeFirstDone(chunk) && !unlocked.has(chunk.id);
|
||||
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
@@ -32,11 +37,18 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
{tab === 'commentary' && <CommentaryChunkTab resources={resources} chunk={chunk} project={project} chapterIndex={chapterIndex} updateChunk={updateChunk} notify={notify} />}
|
||||
{held && (
|
||||
<div role="note" className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 text-sm">
|
||||
<p className="font-medium">Observe first</p>
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">Write what you notice in the text before reading what others say. Commentary opens after {OBSERVE_FIRST_MIN} words of your own Observe notes ({ownWords(chunk, 'observation')} so far).</p>
|
||||
<button type="button" onClick={() => setUnlocked((u) => new Set(u).add(chunk.id))} className="mt-2 text-xs text-[var(--r-accent)] hover:underline">Show it anyway</button>
|
||||
</div>
|
||||
)}
|
||||
{!held && tab === 'commentary' && <CommentaryChunkTab resources={resources} chunk={chunk} project={project} chapterIndex={chapterIndex} updateChunk={updateChunk} notify={notify} />}
|
||||
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
|
||||
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
|
||||
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
|
||||
{tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
|
||||
{!held && tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// The "More" area's session-facing notes: a teach-it-back summary, who came, what the group said, and follow-ups.
|
||||
import { makeId } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
const areaCls = 'mt-1 w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
|
||||
export default function SessionNotes({ chunk, updateChunk }) {
|
||||
const followUps = chunk.followUps ?? [];
|
||||
const setFollowUps = (next) => updateChunk(chunk.id, { followUps: next });
|
||||
const add = (text) => { const t = text.trim(); if (t) setFollowUps([...followUps, { id: makeId(), text: t, done: false }]); };
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label className="block text-xs text-[var(--r-sub)]">
|
||||
Teach it back <span className="opacity-70">(explain the passage in three sentences, as you would to someone else)</span>
|
||||
<textarea value={chunk.teachBack ?? ''} rows={3} onChange={(e) => updateChunk(chunk.id, { teachBack: e.target.value })} className={areaCls} />
|
||||
</label>
|
||||
<label className="block text-xs text-[var(--r-sub)]">
|
||||
Who came
|
||||
<input value={chunk.attendees ?? ''} onChange={(e) => updateChunk(chunk.id, { attendees: e.target.value })} placeholder="Names, separated by commas"
|
||||
className="mt-1 w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none" />
|
||||
</label>
|
||||
<label className="block text-xs text-[var(--r-sub)]">
|
||||
What the group said
|
||||
<textarea value={chunk.groupNotes ?? ''} rows={3} onChange={(e) => updateChunk(chunk.id, { groupNotes: e.target.value })} className={areaCls} />
|
||||
</label>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--r-sub)]">Follow-ups</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{followUps.map((f) => (
|
||||
<li key={f.id} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" aria-label={`Done: ${f.text}`} checked={!!f.done} onChange={() => setFollowUps(followUps.map((x) => (x.id === f.id ? { ...x, done: !x.done } : x)))} className="accent-[var(--r-accent)]" />
|
||||
<span className={f.done ? 'text-[var(--r-sub)] line-through' : ''}>{f.text}</span>
|
||||
<button type="button" aria-label={`Remove follow-up: ${f.text}`} onClick={() => setFollowUps(followUps.filter((x) => x.id !== f.id))} className="ml-auto text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<input aria-label="Add a follow-up" placeholder="Something to follow up on, then Enter"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(e.target.value); e.target.value = ''; } }}
|
||||
className="mt-1.5 w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,21 +28,21 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
|
||||
<div className="safe-top z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
|
||||
<div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3">
|
||||
<HomeButton onClick={goHome} />
|
||||
<div className="min-w-0 max-w-[16rem] truncate text-sm font-medium">{project?.title ?? ''}</div>
|
||||
<nav aria-label="Study stages" className="mx-auto flex gap-0.5">
|
||||
<div className="hidden min-w-0 max-w-[16rem] truncate text-sm font-medium sm:block">{project?.title ?? ''}</div>
|
||||
<nav aria-label="Study stages" className="flex min-w-0 flex-1 gap-0.5 overflow-x-auto sm:justify-center [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{STAGES.map((s) => {
|
||||
const disabled = s.id !== 'setup' && (!project || noChunks);
|
||||
return (
|
||||
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
|
||||
title={disabled ? 'Add a chunk in Chunks first' : undefined}
|
||||
onClick={() => setCurrentPage(s.id)}
|
||||
className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
|
||||
className={`shrink-0 rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex min-w-[7rem] items-center justify-end gap-2 text-xs text-[var(--r-sub)]" aria-live="polite">
|
||||
<div className="flex shrink-0 items-center justify-end sm:min-w-[7rem] gap-2 text-xs text-[var(--r-sub)]" aria-live="polite">
|
||||
{loading ? <span>Loading…</span>
|
||||
: saveStatus ? <span className="text-[var(--r-accent)]">{saveStatus}</span>
|
||||
: syncStatus === 'syncing' ? <span>Syncing…</span>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -419,12 +419,22 @@ describe('Study workspace', () => {
|
||||
|
||||
test('commentary: "Add to notes" quotes a paragraph with its citation', async () => {
|
||||
const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
|
||||
expect(screen.getByText('Observe first')).toBeInTheDocument(); // commentary waits for your own notes
|
||||
await user.click(screen.getByRole('button', { name: /show it anyway/i }));
|
||||
await user.click(await screen.findByRole('button', { name: /add to notes/i }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i);
|
||||
await user.click(screen.getByRole('button', { name: /^more/i }));
|
||||
expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)');
|
||||
});
|
||||
|
||||
test('commentary opens once you have written enough of your own Observe notes', async () => {
|
||||
await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
|
||||
expect(screen.getByText('Observe first')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: Array.from({ length: 15 }, (_, i) => `word${i}`).join(' ') } });
|
||||
expect(screen.queryByText('Observe first')).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: /add to notes/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('people & places: lists what is tagged in the chunk', async () => {
|
||||
const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
|
||||
await user.click(screen.getByRole('tab', { name: /people & places/i }));
|
||||
|
||||
@@ -89,3 +89,14 @@ export function mergeReadingPlans(local, incoming) {
|
||||
activity: [...new Set([...(local.activity ?? []), ...(incoming.activity ?? [])])].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
// When you last downloaded a backup on this device, so Home can say so (and nudge when it has been a while).
|
||||
const LAST_BACKUP_KEY = 'last-backup-at';
|
||||
export const markBackedUp = (now = Date.now()) => { try { localStorage.setItem(LAST_BACKUP_KEY, String(now)); } catch { /* storage unavailable */ } };
|
||||
export const lastBackupAt = () => { try { return Number(localStorage.getItem(LAST_BACKUP_KEY)) || null; } catch { return null; } };
|
||||
export function backupAge(at, now = Date.now()) {
|
||||
if (!at) return { text: 'You haven’t downloaded a backup yet', stale: true };
|
||||
const days = Math.floor((now - at) / 86400000);
|
||||
if (days < 1) return { text: 'Backed up today', stale: false };
|
||||
return { text: `Last backup ${days} day${days === 1 ? '' : 's'} ago`, stale: days >= 14 };
|
||||
}
|
||||
@@ -68,3 +68,13 @@ test('prayers are in the backup, deleted ones are not, and they survive validati
|
||||
expect(checked.backup.prayers[0].text).toBe('Peace');
|
||||
expect(validateBackup({ app: 'bible-study-project', version: 1, prayers: [{ id: 'x' }, 5, { id: 'y', text: ' ' }] }).backup.prayers).toEqual([]);
|
||||
});
|
||||
|
||||
import { backupAge } from './backup.js';
|
||||
|
||||
test('the backup age says how long ago, and flags a long gap', () => {
|
||||
const now = 1_000_000_000_000;
|
||||
expect(backupAge(null, now)).toMatchObject({ stale: true });
|
||||
expect(backupAge(now - 3600_000, now)).toEqual({ text: 'Backed up today', stale: false });
|
||||
expect(backupAge(now - 5 * 86400000, now)).toEqual({ text: 'Last backup 5 days ago', stale: false });
|
||||
expect(backupAge(now - 20 * 86400000, now).stale).toBe(true);
|
||||
});
|
||||
+16
-7
@@ -30,16 +30,24 @@ export function questionsFor(chunk) {
|
||||
return fallback.length ? { kind: 'think', items: fallback } : { kind: 'none', items: [] };
|
||||
}
|
||||
|
||||
function sessionHtml(project, session, { answerLines }) {
|
||||
// layout: 'standard' (everything), 'questions' (passage + questions only, one page), 'large' (big print), 'notes' (passage and a blank page to write).
|
||||
export const HANDOUT_LAYOUTS = [
|
||||
{ id: 'standard', label: 'Standard: passage, outline, questions' },
|
||||
{ id: 'questions', label: 'Questions only: passage and discussion questions' },
|
||||
{ id: 'large', label: 'Large print: passage and questions' },
|
||||
{ id: 'notes', label: 'Note-taking: passage and lots of lines' },
|
||||
];
|
||||
|
||||
function sessionHtml(project, session, { answerLines, layout = 'standard' }) {
|
||||
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 outline = layout === 'standard' ? flatten(outlineForSession(project, chunk)) : [];
|
||||
const q = layout === 'notes' ? { kind: 'none', items: [] } : questionsFor(chunk);
|
||||
const blank = (n) => Array.from({ length: n }, () => '<div class="rule"></div>').join('');
|
||||
return `<section class="page">
|
||||
return `<section class="page${layout === 'standard' ? '' : ` ${layout}`}">
|
||||
<header>
|
||||
<p class="kicker">${esc(series || project.title)}</p>
|
||||
<h1>Session ${esc(num)}${title ? `: ${esc(title)}` : ''}</h1>
|
||||
@@ -49,13 +57,13 @@ function sessionHtml(project, session, { answerLines }) {
|
||||
${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)}` : ''}
|
||||
${answerLines || layout === 'notes' ? `<h2>Notes</h2>${blank(layout === 'notes' ? 16 : 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 } = {}) {
|
||||
export function buildHandoutHtml(project, ids = null, { answerLines = true, layout = 'standard' } = {}) {
|
||||
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; }
|
||||
@@ -71,8 +79,9 @@ export function buildHandoutHtml(project, ids = null, { answerLines = true } = {
|
||||
.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; }
|
||||
.large { font-size: 17pt; line-height: 1.55; } .large h1 { font-size: 30pt; } .large .rule { height: 2.2em; }
|
||||
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>`;
|
||||
</style></head><body>${sessions.map((s) => sessionHtml(project, s, { answerLines, layout })).join('')}</body></html>`;
|
||||
}
|
||||
|
||||
export function printHandout(project, ids = null, options) {
|
||||
|
||||
@@ -46,3 +46,19 @@ test('one session only, and no writing lines when switched off', () => {
|
||||
expect(html).toContain('Session 2');
|
||||
expect(html).not.toContain('class="rule"');
|
||||
});
|
||||
|
||||
import { buildHandoutHtml as buildLayoutHtml } from './handout.js';
|
||||
|
||||
test('layouts change what the handout prints', () => {
|
||||
const project = { id: 'p', title: 'T', translation: 'BSB', outline: [], chapters: [{ book: 'John', bookAbbrev: 'Jhn', chapter: '3', verses: [{ number: 16, text: 'For God so loved.' }], chunks: [{ id: 'c', startVerse: 16, endVerse: 16, discussionQuestions: 'Why did he give?', application: 'Trust him.', outline: [{ id: 'n', text: 'Point one', children: [] }] }] }] };
|
||||
const standard = buildLayoutHtml(project);
|
||||
const questions = buildLayoutHtml(project, null, { layout: 'questions' });
|
||||
const notes = buildLayoutHtml(project, null, { layout: 'notes' });
|
||||
const large = buildLayoutHtml(project, null, { layout: 'large' });
|
||||
expect(standard).toContain('Point one');
|
||||
expect(questions).not.toContain('Point one');
|
||||
expect(questions).toContain('Why did he give?');
|
||||
expect(notes).not.toContain('Why did he give?');
|
||||
expect(notes.match(/class="rule"/g).length).toBeGreaterThanOrEqual(16);
|
||||
expect(large).toContain('class="page large"');
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// Looks across every study at once: this week's own work, tags as themes, the original-language words you
|
||||
// keep returning to, what slipped in recalls, and follow-ups still open. `projects` are full study projects.
|
||||
import { formatChunkReference } from './bibleUtils.jsx';
|
||||
import { NOTE_FIELDS, ownShare, ownWords } from './ownWork.js';
|
||||
import { WEEK_MS } from './recall.js';
|
||||
|
||||
const lines = (text) => String(text ?? '').split('\n').map((l) => l.replace(/^\s*(?:[-*•–]|\d{1,2}[.)])\s+/, '').trim()).filter(Boolean);
|
||||
|
||||
// Every chunk with where it lives.
|
||||
function everyChunk(projects) {
|
||||
return (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).map((chunk) => ({
|
||||
project, chunk, projectId: project.id, projectTitle: project.title, chunkId: chunk.id,
|
||||
reference: formatChunkReference(project, chapterIndex, chunk, '–'),
|
||||
}))));
|
||||
}
|
||||
|
||||
// Chunks edited in the last 7 days: how many, your own words in them, the share that is yours, and recalls done.
|
||||
export function weeklySummary(projects, now = Date.now()) {
|
||||
const all = everyChunk(projects);
|
||||
const recent = all.filter(({ chunk }) => now - (chunk.editedAt ?? 0) < WEEK_MS && (chunk.editedAt ?? 0) > 0);
|
||||
const ownWordsTotal = recent.reduce((n, { chunk }) => n + NOTE_FIELDS.reduce((m, f) => m + ownWords(chunk, f), 0), 0);
|
||||
const shares = recent.map(({ chunk }) => ownShare(chunk)).filter((s) => s !== null);
|
||||
return {
|
||||
chunksTouched: recent.length,
|
||||
ownWords: ownWordsTotal,
|
||||
ownShare: shares.length ? Math.round(shares.reduce((a, b) => a + b, 0) / shares.length) : null,
|
||||
recalls: all.reduce((n, { chunk }) => n + (chunk.recalls ?? []).filter((r) => now - (r.at ?? 0) < WEEK_MS).length, 0),
|
||||
teachBacks: recent.filter(({ chunk }) => (chunk.teachBack ?? '').trim()).length,
|
||||
};
|
||||
}
|
||||
|
||||
const snippet = (chunk) => {
|
||||
const raw = (chunk.observation || chunk.interpretation || chunk.application || '').replace(/\s+/g, ' ').trim();
|
||||
return raw.length > 110 ? `${raw.slice(0, 110)}…` : raw;
|
||||
};
|
||||
|
||||
// Tags across studies: [{ tag, count, items: [{ projectId, projectTitle, chunkId, reference, snippet }] }], most used first.
|
||||
export function themeIndex(projects) {
|
||||
const map = new Map();
|
||||
for (const entry of everyChunk(projects)) {
|
||||
for (const tag of entry.chunk.tags ?? []) {
|
||||
const key = tag.trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
if (!map.has(key)) map.set(key, { tag: tag.trim(), items: [] });
|
||||
map.get(key).items.push({ projectId: entry.projectId, projectTitle: entry.projectTitle, chunkId: entry.chunkId, reference: entry.reference, snippet: snippet(entry.chunk) });
|
||||
}
|
||||
}
|
||||
return [...map.values()].map((t) => ({ ...t, count: t.items.length })).sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||
}
|
||||
|
||||
// Original-language words studied in more than one place (or all, with minCount 1): most often first.
|
||||
export function wordIndex(projects, { minCount = 2 } = {}) {
|
||||
const map = new Map();
|
||||
for (const entry of everyChunk(projects)) {
|
||||
for (const w of entry.chunk.greekWords ?? []) {
|
||||
const strong = (w.strongNumber ?? '').toUpperCase();
|
||||
const key = strong || (w.lexeme ?? w.query ?? '').toLowerCase();
|
||||
if (!key) continue;
|
||||
if (!map.has(key)) map.set(key, { key, strong, lexeme: w.lexeme || w.query || key, gloss: w.englishGloss || w.shortDefinition || '', transliteration: w.transliteration ?? '', places: [] });
|
||||
map.get(key).places.push({ projectId: entry.projectId, projectTitle: entry.projectTitle, chunkId: entry.chunkId, reference: entry.reference });
|
||||
}
|
||||
}
|
||||
return [...map.values()].map((w) => ({ ...w, count: w.places.length })).filter((w) => w.count >= minCount).sort((a, b) => b.count - a.count || a.lexeme.localeCompare(b.lexeme));
|
||||
}
|
||||
|
||||
// What you said slipped in your recalls, newest first: [{ at, reference, projectId, chunkId, items: [line] }].
|
||||
export function slippedList(projects) {
|
||||
return everyChunk(projects).flatMap((e) => (e.chunk.recalls ?? [])
|
||||
.map((r) => ({ at: r.at ?? 0, reference: e.reference, projectId: e.projectId, projectTitle: e.projectTitle, chunkId: e.chunkId, items: lines(r.gaps) }))
|
||||
.filter((r) => r.items.length)).sort((a, b) => b.at - a.at);
|
||||
}
|
||||
|
||||
// Follow-ups across studies: open ones first (then done), newest chunk first.
|
||||
export function followUpList(projects) {
|
||||
return everyChunk(projects).flatMap((e) => (e.chunk.followUps ?? []).map((f) => ({
|
||||
id: f.id, text: f.text, done: !!f.done, reference: e.reference, projectId: e.projectId, projectTitle: e.projectTitle, chunkId: e.chunkId, editedAt: e.chunk.editedAt ?? 0,
|
||||
}))).sort((a, b) => Number(a.done) - Number(b.done) || b.editedAt - a.editedAt);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { weeklySummary, themeIndex, wordIndex, slippedList, followUpList } from './insights.js';
|
||||
import { WEEK_MS } from './recall.js';
|
||||
|
||||
const NOW = 100 * WEEK_MS;
|
||||
const text = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
|
||||
const chunk = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 5, observation: text(20), editedAt: NOW - 1000, ...extra });
|
||||
const study = (chunks, id = 'p1', title = 'Romans') => ({ id, title, translation: 'BSB', chapters: [{ book: 'Romans', bookAbbrev: 'Rom', chapter: 8, chunks }] });
|
||||
|
||||
test('the weekly summary counts only chunks edited this week', () => {
|
||||
const pasted = text(10);
|
||||
const projects = [study([
|
||||
chunk('a', { teachBack: 'x', recalls: [{ at: NOW - 1000, rating: 'solid' }] }),
|
||||
chunk('b', { observation: `${text(10)} ${pasted}`, aiPastes: { observation: [pasted] }, editedAt: NOW - 500 }),
|
||||
chunk('old', { editedAt: NOW - 3 * WEEK_MS, recalls: [{ at: NOW - 3 * WEEK_MS, rating: 'solid' }] }),
|
||||
])];
|
||||
const s = weeklySummary(projects, NOW);
|
||||
expect(s).toMatchObject({ chunksTouched: 2, ownWords: 30, recalls: 1, teachBacks: 1 });
|
||||
expect(s.ownShare).toBe(75); // 100% and 50%, averaged
|
||||
expect(weeklySummary([], NOW).ownShare).toBeNull();
|
||||
});
|
||||
|
||||
test('tags group notes from different studies into themes, most used first', () => {
|
||||
const p1 = study([chunk('a', { tags: ['Grace'] }), chunk('b', { tags: ['hope'] })]);
|
||||
const p2 = study([chunk('c', { tags: ['grace '] })], 'p2', 'John');
|
||||
const themes = themeIndex([p1, p2]);
|
||||
expect(themes.map((t) => [t.tag, t.count])).toEqual([['Grace', 2], ['hope', 1]]);
|
||||
expect(themes[0].items.map((i) => i.projectTitle)).toEqual(['Romans', 'John']);
|
||||
});
|
||||
|
||||
test('words that come up in more than one place are listed', () => {
|
||||
const w = (n, g) => ({ id: n, strongNumber: n, lexeme: g, englishGloss: g });
|
||||
const projects = [study([chunk('a', { greekWords: [w('G26', 'agape'), w('G4102', 'pistis')] }), chunk('b', { greekWords: [w('g26', 'agape')] })])];
|
||||
const list = wordIndex(projects);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]).toMatchObject({ strong: 'G26', count: 2 });
|
||||
expect(wordIndex(projects, { minCount: 1 })).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('slipped lines and follow-ups are collected, open follow-ups first', () => {
|
||||
const projects = [study([chunk('a', {
|
||||
recalls: [{ at: 5, gaps: '- the serpent\nwho spoke', rating: 'partly' }, { at: 9, gaps: '', rating: 'solid' }],
|
||||
followUps: [{ id: '1', text: 'done thing', done: true }, { id: '2', text: 'call Sam', done: false }],
|
||||
})])];
|
||||
expect(slippedList(projects)).toHaveLength(1);
|
||||
expect(slippedList(projects)[0].items).toEqual(['the serpent', 'who spoke']);
|
||||
expect(followUpList(projects).map((f) => f.text)).toEqual(['call Sam', 'done thing']);
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
// conflict doesn't force a choice between them. Chunks are matched by id; each chunk carries the time it
|
||||
// was last edited here (editedAt). Nothing the person wrote is dropped: where both copies changed the
|
||||
// same note, the newer one stays and the older one is appended under a marker.
|
||||
const TEXT_FIELDS = ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript', 'episodeNumber', 'episodeTitle'];
|
||||
const TEXT_FIELDS = ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript', 'episodeNumber', 'episodeTitle', 'teachBack', 'attendees', 'groupNotes'];
|
||||
const LIST_FIELDS = ['tags', 'crossReferences', 'greekWords'];
|
||||
export const MERGE_MARKER = '— from your other device —';
|
||||
|
||||
@@ -22,6 +22,12 @@ function union(a = [], b = []) {
|
||||
return [...a, ...b].filter((item) => { const k = JSON.stringify(item); if (seen.has(k)) return false; seen.add(k); return true; });
|
||||
}
|
||||
|
||||
// Follow-ups are matched by id; the newer copy's wording and done-state win, nothing is dropped.
|
||||
function mergeById(newer = [], older = []) {
|
||||
const seen = new Set(newer.map((i) => i.id));
|
||||
return [...newer, ...older.filter((i) => !seen.has(i.id))];
|
||||
}
|
||||
|
||||
export function mergeChunk(mine, theirs) {
|
||||
if (same(mine, theirs)) return { chunk: mine, changed: false };
|
||||
const mineNewer = (mine.editedAt ?? 0) >= (theirs.editedAt ?? 0);
|
||||
@@ -31,6 +37,7 @@ export function mergeChunk(mine, theirs) {
|
||||
LIST_FIELDS.forEach((f) => { out[f] = union(newer[f], older[f]); });
|
||||
// Pastes follow their text: keep the union per field so merged notes still show what was pasted.
|
||||
out.aiPastes = Object.fromEntries(['observation', 'interpretation', 'application'].map((f) => [f, union(newer.aiPastes?.[f], older.aiPastes?.[f])]));
|
||||
out.followUps = mergeById(newer.followUps, older.followUps);
|
||||
out.recalls = union(newer.recalls, older.recalls);
|
||||
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
|
||||
return { chunk: out, changed: !same(out, mine) };
|
||||
|
||||
@@ -47,3 +47,7 @@ export function thinFields(chunk) {
|
||||
export function chunksNeedingWork(chunks) {
|
||||
return (chunks ?? []).map((chunk) => ({ chunk, missing: thinFields(chunk) })).filter((entry) => entry.missing.length > 0);
|
||||
}
|
||||
|
||||
// Commentary and study aids stay folded away until you've written this much of your own Observe notes.
|
||||
export const OBSERVE_FIRST_MIN = 15;
|
||||
export const observeFirstDone = (chunk) => ownWords(chunk, 'observation') >= OBSERVE_FIRST_MIN;
|
||||
@@ -13,18 +13,55 @@ const snippetAround = (raw, at, len) => {
|
||||
return (start > 0 ? '…' : '') + raw.slice(start, end).replace(/\s+/g, ' ') + (end < raw.length ? '…' : '');
|
||||
};
|
||||
|
||||
const DAY = 86400000;
|
||||
|
||||
// "grace in Romans last year" -> { text: 'grace', book: 'Romans', since, until }. Words it understands:
|
||||
// "in <book>", "last|this week|month|year" (last = the past 7 / 30 / 365 days; this year = since 1 January) and a
|
||||
// four-digit year. Whatever is left is the text to look for.
|
||||
export function parseSearchQuery(query, now = Date.now()) {
|
||||
let text = ` ${query} `;
|
||||
let book = null;
|
||||
let since = null;
|
||||
let until = null;
|
||||
for (const b of [...bookOptions].sort((x, y) => y.name.length - x.name.length)) {
|
||||
const re = new RegExp(`\\s(?:in|from)\\s+${b.name.toLowerCase().replace(/\s+/g, '\\s+')}(?=\\s)`, 'i');
|
||||
if (re.test(text)) { book = b.name; text = text.replace(re, ' '); break; }
|
||||
}
|
||||
const rel = text.match(/\s(last|this)\s+(week|month|year)(?=\s)/i);
|
||||
if (rel) {
|
||||
const [, which, unit] = rel;
|
||||
const span = { week: 7, month: 30, year: 365 }[unit.toLowerCase()];
|
||||
since = which.toLowerCase() === 'last' ? now - span * DAY : (unit.toLowerCase() === 'year' ? new Date(new Date(now).getFullYear(), 0, 1).getTime() : now - span * DAY);
|
||||
text = text.replace(rel[0], ' ');
|
||||
}
|
||||
const year = text.match(/\s(20\d\d)(?=\s)/);
|
||||
if (year) {
|
||||
since = new Date(Number(year[1]), 0, 1).getTime();
|
||||
until = new Date(Number(year[1]) + 1, 0, 1).getTime();
|
||||
text = text.replace(year[0], ' ');
|
||||
}
|
||||
return { text: text.replace(/\s+/g, ' ').trim(), book, since, until };
|
||||
}
|
||||
|
||||
// projects: full project objects. -> [{ projectId, projectTitle, chunkId, ref, field, snippet }], at most
|
||||
// `limit`, one hit per chunk (the first field that matches).
|
||||
export function searchNotes(projects, query, { limit = 20 } = {}) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q.length < 3) return [];
|
||||
export function searchNotes(projects, query, { limit = 20, now = Date.now() } = {}) {
|
||||
const parsed = parseSearchQuery(query, now);
|
||||
const q = parsed.text.toLowerCase();
|
||||
const filtered = !!(parsed.book || parsed.since);
|
||||
if (q.length < 3 && !(filtered && q.length === 0)) return [];
|
||||
const hits = [];
|
||||
for (const project of projects) {
|
||||
for (const [chapterIndex, chapter] of (project.chapters ?? []).entries()) {
|
||||
if (parsed.book && chapter.book !== parsed.book) continue;
|
||||
for (const chunk of chapter.chunks ?? []) {
|
||||
const when = chunk.editedAt ?? project.lastEdited ?? 0;
|
||||
if (parsed.since && when < parsed.since) continue;
|
||||
if (parsed.until && when >= parsed.until) continue;
|
||||
for (const [field, label] of NOTE_FIELDS) {
|
||||
const raw = chunk[field] ?? '';
|
||||
const at = raw.toLowerCase().indexOf(q);
|
||||
if (!q && !raw.trim()) continue; // a passage/date search lists chunks that have something written
|
||||
const at = q ? raw.toLowerCase().indexOf(q) : 0;
|
||||
if (at === -1) continue;
|
||||
hits.push({
|
||||
projectId: project.id, projectTitle: project.title, chunkId: chunk.id,
|
||||
|
||||
@@ -40,3 +40,25 @@ test('finds highlights by their words or colour name, and bookmarks by reference
|
||||
expect(searchMarks(marks, 'promise')[0]).toMatchObject({ kind: 'highlight' });
|
||||
expect(searchMarks(marks, 'john 3')[0]).toMatchObject({ kind: 'bookmark', chapter: 3, verse: 16 });
|
||||
});
|
||||
|
||||
import { parseSearchQuery as parseQ } from './personalSearch.js';
|
||||
|
||||
test('search understands "in <book>" and time words', () => {
|
||||
const now = new Date('2026-10-14T12:00:00Z').getTime();
|
||||
const q = parseQ('grace in Romans last year', now);
|
||||
expect(q.text).toBe('grace');
|
||||
expect(q.book).toBe('Romans');
|
||||
expect(q.since).toBe(now - 365 * 86400000);
|
||||
expect(parseQ('hope 2025', now)).toMatchObject({ text: 'hope', since: new Date(2025, 0, 1).getTime(), until: new Date(2026, 0, 1).getTime() });
|
||||
expect(parseQ('plain words', now)).toMatchObject({ text: 'plain words', book: null, since: null });
|
||||
});
|
||||
|
||||
test('a passage and date search filters notes, even with no words to match', () => {
|
||||
const now = new Date('2026-10-14T12:00:00Z').getTime();
|
||||
const day = 86400000;
|
||||
const make = (book, editedAt, observation) => ({ id: book + editedAt, title: 'S', lastEdited: now, chapters: [{ book, bookAbbrev: book.slice(0, 3), chapter: '1', chunks: [{ id: `${book}${editedAt}`, startVerse: 1, endVerse: 3, observation, editedAt }] }] });
|
||||
const projects = [make('Romans', now - 10 * day, 'grace abounds'), make('Romans', now - 400 * day, 'grace long ago'), make('John', now - 10 * day, 'grace again')];
|
||||
expect(searchNotes(projects, 'grace in Romans last year', { now })).toHaveLength(1);
|
||||
expect(searchNotes(projects, 'in John last month', { now })).toHaveLength(1);
|
||||
expect(searchNotes(projects, 'grace', { now })).toHaveLength(3);
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
const FIELD_NAMES = {
|
||||
observation: 'Observe', interpretation: 'Interpret', application: 'Apply', generalNotes: 'Background', finalScript: 'Script',
|
||||
discussionQuestions: 'Questions', episodeNumber: 'Session number', episodeTitle: 'Session title',
|
||||
teachBack: 'Teach it back', attendees: 'Who came', groupNotes: 'Group notes',
|
||||
};
|
||||
const same = (a, b) => JSON.stringify(a ?? '') === JSON.stringify(b ?? '');
|
||||
|
||||
|
||||
Reference in new issue
Block a user