diff --git a/src/App.jsx b/src/App.jsx index 23d9e31..ae41543 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 ; if (currentPage === 'reader') return ; if (currentPage === 'import') return ; + if (currentPage === 'insights') return ; if (currentPage === 'study') return ; if (currentPage === 'outline') return ; if (currentPage === 'script') return ; diff --git a/src/components/HandoutExport.jsx b/src/components/HandoutExport.jsx index 6abc32b..43d4004 100644 --- a/src/components/HandoutExport.jsx +++ b/src/components/HandoutExport.jsx @@ -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 ( Lesson handout @@ -20,6 +21,11 @@ export default function HandoutExport({ project }) { {sessions.map((s) => {s.number}. {s.reference})} + Layout + 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) => {l.label})} + + setLines(e.target.checked)} className="accent-[var(--r-accent)]" /> Lines to write on Print / Save PDF diff --git a/src/components/WeeklyRecall.jsx b/src/components/WeeklyRecall.jsx index 7a30379..2d38804 100644 --- a/src/components/WeeklyRecall.jsx +++ b/src/components/WeeklyRecall.jsx @@ -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() { ))} + What did you miss or get wrong? (one per line; collected in Insights) + 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" /> + How did you do? {RATE.map((r) => ( diff --git a/src/components/settings/DataSection.jsx b/src/components/settings/DataSection.jsx index c9f6046..b1ea71b 100644 --- a/src/components/settings/DataSection.jsx +++ b/src/components/settings/DataSection.jsx @@ -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`); }; diff --git a/src/components/study/CompareSheet.jsx b/src/components/study/CompareSheet.jsx new file mode 100644 index 0000000..b2d1326 --- /dev/null +++ b/src/components/study/CompareSheet.jsx @@ -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 Pick a passage to compare.; + const verses = getChunkVerseEntries(entry.project, entry.chapterIndex, entry.chunk); + return ( + + {entry.label} + + {verses.map((v) => {v.number}{v.text})} + + {FIELDS.map(([f, label]) => ( + + {label} + {entry.chunk[f]?.trim() || Nothing written.} + + ))} + + ); +} + +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 ( + + + Compare passages + 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"> + Choose a passage to set beside this one… + {all.filter((c) => c.key !== mine?.key).map((c) => {c.label})} + + Close + + + + + + + ); +} diff --git a/src/components/study/NotesPane.jsx b/src/components/study/NotesPane.jsx index c272a4d..576e85a 100644 --- a/src/components/study/NotesPane.jsx +++ b/src/components/study/NotesPane.jsx @@ -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 updateChunk(chunk.id, { aiPastes: clearPastes(chunk, field) })} className="text-[var(--r-accent)] hover:underline">That’s mine )} - updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /> + {field === 'application' && onPray && ( + Pray about this → + )} + updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /> )} @@ -106,6 +110,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav 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}`} /> + {saveTemplate && ( {!tplOpen ? ( diff --git a/src/components/study/ResourcePane.jsx b/src/components/study/ResourcePane.jsx index 6b17906..5e11a9b 100644 --- a/src/components/study/ResourcePane.jsx +++ b/src/components/study/ResourcePane.jsx @@ -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 ( @@ -32,11 +37,18 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex ))} - {tab === 'commentary' && } + {held && ( + + Observe first + 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). + setUnlocked((u) => new Set(u).add(chunk.id))} className="mt-2 text-xs text-[var(--r-accent)] hover:underline">Show it anyway + + )} + {!held && tab === 'commentary' && } {tab === 'words' && } {tab === 'refs' && } {tab === 'people' && } - {tab === 'aids' && } + {!held && tab === 'aids' && } ); diff --git a/src/components/study/SessionNotes.jsx b/src/components/study/SessionNotes.jsx new file mode 100644 index 0000000..efaf841 --- /dev/null +++ b/src/components/study/SessionNotes.jsx @@ -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 ( + + + Teach it back (explain the passage in three sentences, as you would to someone else) + updateChunk(chunk.id, { teachBack: e.target.value })} className={areaCls} /> + + + Who came + 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" /> + + + What the group said + updateChunk(chunk.id, { groupNotes: e.target.value })} className={areaCls} /> + + + Follow-ups + + {followUps.map((f) => ( + + setFollowUps(followUps.map((x) => (x.id === f.id ? { ...x, done: !x.done } : x)))} className="accent-[var(--r-accent)]" /> + {f.text} + setFollowUps(followUps.filter((x) => x.id !== f.id))} className="ml-auto text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">× + + ))} + + { 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" /> + + + ); +} diff --git a/src/components/study/StudioShell.jsx b/src/components/study/StudioShell.jsx index 744597f..97dac39 100644 --- a/src/components/study/StudioShell.jsx +++ b/src/components/study/StudioShell.jsx @@ -28,21 +28,21 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals - {project?.title ?? ''} - + {project?.title ?? ''} + {STAGES.map((s) => { const disabled = s.id !== 'setup' && (!project || noChunks); return ( 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} ); })} - + {loading ? Loading… : saveStatus ? {saveStatus} : syncStatus === 'syncing' ? Syncing… diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index cd0ce36..d2fe3fa 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -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={( <> + setCurrentPage('insights')} className={linkBtn} title="This week, themes, words, what slipped, follow-ups">Insights Import Session List + New Project > @@ -137,6 +141,15 @@ export default function HomePage() { > + {projectIndex.length > 0 && (() => { + const age = backupAge(lastBackupAt()); + return ( + + {age.text}. + Back up now + + ); + })()} diff --git a/src/pages/InsightsPage.jsx b/src/pages/InsightsPage.jsx new file mode 100644 index 0000000..6c4459b --- /dev/null +++ b/src/pages/InsightsPage.jsx @@ -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 ( + + {value} + {label} + + ); +} + +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) => openStudyChunk(item.projectId, item.chunkId)} className={open}>{item.reference} · {item.projectTitle}; + + return ( + + + + This week + Counted over the chunks you edited in the last 7 days. Pasted text isn’t counted as yours. + + + + + + + {week.teachBacks > 0 && {week.teachBacks} teach-it-back summar{week.teachBacks === 1 ? 'y' : 'ies'} written.} + + + + Follow-ups + {followUps.length === 0 ? None yet. Add them under “More” in the Study stage. : ( + + {followUps.map((f) => ( + + {f.text} + {link(f)} + + ))} + + )} + + + + What slipped in recalls + {slipped.length === 0 ? Nothing yet. After a weekly recall, note what you missed. : ( + + {slipped.map((r) => ( + + {new Date(r.at).toLocaleDateString()} · {link(r)} + {r.items.map((i) => {i})} + + ))} + + )} + + + + Themes + {themes.length === 0 ? Tag a chunk (under “More”) and it appears here with every other chunk on the same theme. : ( + + {themes.map((t) => ( + + {t.tag} · {t.count} chunk{t.count === 1 ? '' : 's'} + + {t.items.map((i) => ( + {link(i)}{i.snippet && {i.snippet}} + ))} + + + ))} + + )} + + + + Words you keep studying + {words.length === 0 ? Words you study in two or more places show up here. : ( + + {words.map((w) => ( + + {w.lexeme} {[w.transliteration, w.strong, w.gloss].filter(Boolean).join(' · ')} · {w.count} places + {w.places.map((p) => {link(p)})} + + ))} + + )} + + + + ); +} diff --git a/src/pages/StudyPage.jsx b/src/pages/StudyPage.jsx index 0fd405e..8de0c08 100644 --- a/src/pages/StudyPage.jsx +++ b/src/pages/StudyPage.jsx @@ -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() { )} + 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… ‹ = allChunks.length - 1} aria-label="Next chunk" @@ -132,7 +135,8 @@ export default function StudyPage() { Your notes - + startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} /> @@ -148,6 +152,7 @@ export default function StudyPage() { + {comparing && selectedChunk && setComparing(false)} />} {statusMessage && ( diff --git a/src/test/App.home.test.jsx b/src/test/App.home.test.jsx index 4f387cb..dfa2c24 100644 --- a/src/test/App.home.test.jsx +++ b/src/test/App.home.test.jsx @@ -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 })); diff --git a/src/utils/backup.js b/src/utils/backup.js index 3580801..96fa5e6 100644 --- a/src/utils/backup.js +++ b/src/utils/backup.js @@ -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 }; +} diff --git a/src/utils/backup.test.js b/src/utils/backup.test.js index 769997b..2773917 100644 --- a/src/utils/backup.test.js +++ b/src/utils/backup.test.js @@ -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); +}); diff --git a/src/utils/handout.js b/src/utils/handout.js index da19feb..f1db74f 100644 --- a/src/utils/handout.js +++ b/src/utils/handout.js @@ -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 }, () => '').join(''); - return ` + return ` ${esc(series || project.title)} Session ${esc(num)}${title ? `: ${esc(title)}` : ''} @@ -49,13 +57,13 @@ function sessionHtml(project, session, { answerLines }) { ${verses.length ? `${verses.map((v) => `${esc(v.number)} ${esc(v.text)}`).join('')}` : ''} ${outline.length ? `Outline${outline.map(({ node, depth, label }) => `${esc(label)} ${esc(node.text || '')}${node.ref ? ` (${esc(node.ref)})` : ''}`).join('')}` : ''} ${q.items.length ? `${q.kind === 'questions' ? 'Discussion questions' : 'Think about it'}${q.items.map((item) => `${esc(item)}${answerLines ? blank(2) : ''}`).join('')}` : ''} - ${answerLines ? `Notes${blank(q.items.length ? 4 : 10)}` : ''} + ${answerLines || layout === 'notes' ? `Notes${blank(layout === 'notes' ? 16 : q.items.length ? 4 : 10)}` : ''} `; } // 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 `${esc(project.title)} — handout${sessions.map((s) => sessionHtml(project, s, { answerLines })).join('')}`; +${sessions.map((s) => sessionHtml(project, s, { answerLines, layout })).join('')}
How did you do?
Pick a passage to compare.
{v.number}{v.text}
{label}
{entry.chunk[f]?.trim() || Nothing written.}
Observe first
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).
Follow-ups
+ {age.text}. + Back up now +
{value}
Counted over the chunks you edited in the last 7 days. Pasted text isn’t counted as yours.
{week.teachBacks} teach-it-back summar{week.teachBacks === 1 ? 'y' : 'ies'} written.
None yet. Add them under “More” in the Study stage.
Nothing yet. After a weekly recall, note what you missed.
{new Date(r.at).toLocaleDateString()} · {link(r)}
Tag a chunk (under “More”) and it appears here with every other chunk on the same theme.
{i.snippet}
Words you study in two or more places show up here.
${esc(series || project.title)}
${esc(v.number)} ${esc(v.text)}