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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user