Add study sessions and timeline

This commit is contained in:
2026-07-12 09:29:44 -05:00
parent d710035045
commit 2731c00223
7 changed files with 465 additions and 44 deletions
+340 -19
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { addTagToVerse, getLibraryState, searchVerses } from './studyClient'
import type { ReactNode } from 'react'
import type { Verse } from './types'
@@ -6,13 +6,68 @@ import './App.css'
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
const pageSize = 34
const sessionStorageKey = 'libre-study.sessions.v1'
const activeSessionStorageKey = 'libre-study.active-session.v1'
const timelineLimit = 200
type ViewMode = 'page' | 'study' | 'notes' | 'compare'
type StrongDisplayMode = 'off' | 'highlight' | 'numbers'
type TimelineEntryType = 'verse' | 'strong' | 'footnote' | 'search' | 'navigation' | 'view' | 'setting'
type TimelineEntry = {
id: string
at: string
type: TimelineEntryType
label: string
reference?: string
detail?: string
}
type StudySession = {
id: string
title: string
createdAt: string
updatedAt: string
selectedId: number | null
pageStartId: number | null
viewMode: ViewMode
strongDisplay: StrongDisplayMode
noteDraft: string
timeline: TimelineEntry[]
}
function reference(verse: Verse) {
return `${verse.book} ${verse.chapter}:${verse.verse}`
}
function createSession(title = 'Study Session'): StudySession {
const now = new Date().toISOString()
const id =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
return {
id,
title,
createdAt: now,
updatedAt: now,
selectedId: null,
pageStartId: null,
viewMode: 'notes',
strongDisplay: 'highlight',
noteDraft: '',
timeline: [],
}
}
function timelineId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
function formatTimelineTime(value: string) {
return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
function App() {
const [viewMode, setViewMode] = useState<ViewMode>('notes')
const [query, setQuery] = useState('')
@@ -24,8 +79,11 @@ function App() {
const [strongDisplay, setStrongDisplay] = useState<StrongDisplayMode>('highlight')
const [tagInput, setTagInput] = useState('')
const [noteDraft, setNoteDraft] = useState('')
const [sessions, setSessions] = useState<StudySession[]>([])
const [activeSessionId, setActiveSessionId] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const restoredSessionId = useRef('')
async function loadLibrary() {
setError(null)
@@ -37,6 +95,43 @@ function App() {
setPageStartId((current) => current ?? firstId)
}
function patchActiveSession(patch: Partial<StudySession>) {
if (!activeSessionId) return
const updatedAt = new Date().toISOString()
setSessions((current) =>
current.map((session) =>
session.id === activeSessionId ? { ...session, ...patch, updatedAt } : session,
),
)
}
function recordTimeline(
entry: Omit<TimelineEntry, 'id' | 'at'>,
patch: Partial<StudySession> = {},
) {
if (!activeSessionId) return
const at = new Date().toISOString()
const nextEntry: TimelineEntry = { id: timelineId(), at, ...entry }
setSessions((current) =>
current.map((session) =>
session.id === activeSessionId
? {
...session,
selectedId,
pageStartId,
viewMode,
strongDisplay,
noteDraft,
...patch,
updatedAt: at,
timeline: [nextEntry, ...session.timeline].slice(0, timelineLimit),
}
: session,
),
)
}
async function runSearch(nextQuery = query) {
setError(null)
const trimmed = nextQuery.trim()
@@ -46,6 +141,17 @@ function App() {
const firstResult = results[0]
setSelectedId(firstResult?.id ?? null)
setPageStartId(firstResult ? findPageStartId(firstResult) : null)
recordTimeline(
{
type: 'search',
label: trimmed ? `Search: ${trimmed}` : 'Cleared search',
detail: `${results.length} matches`,
},
{
selectedId: firstResult?.id ?? null,
pageStartId: firstResult ? findPageStartId(firstResult) : null,
},
)
}
async function addTag() {
@@ -65,11 +171,61 @@ function App() {
}
useEffect(() => {
const savedSessions = localStorage.getItem(sessionStorageKey)
const savedActiveSessionId = localStorage.getItem(activeSessionStorageKey)
let nextSessions: StudySession[] = []
if (savedSessions) {
try {
const parsed = JSON.parse(savedSessions) as StudySession[]
nextSessions = Array.isArray(parsed) ? parsed : []
} catch {
nextSessions = []
}
}
if (!nextSessions.length) {
nextSessions = [createSession('KJV Study Session')]
}
setSessions(nextSessions)
setActiveSessionId(
nextSessions.some((session) => session.id === savedActiveSessionId)
? savedActiveSessionId ?? nextSessions[0].id
: nextSessions[0].id,
)
loadLibrary()
.catch((reason) => setError(String(reason)))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (sessions.length) {
localStorage.setItem(sessionStorageKey, JSON.stringify(sessions))
}
}, [sessions])
useEffect(() => {
if (activeSessionId) {
localStorage.setItem(activeSessionStorageKey, activeSessionId)
}
}, [activeSessionId])
useEffect(() => {
if (restoredSessionId.current === activeSessionId) return
const activeSession = sessions.find((session) => session.id === activeSessionId)
if (!activeSession) return
restoredSessionId.current = activeSessionId
setViewMode(activeSession.viewMode)
setStrongDisplay(activeSession.strongDisplay)
setNoteDraft(activeSession.noteDraft)
setSelectedId((current) => activeSession.selectedId ?? current)
setPageStartId((current) => activeSession.pageStartId ?? current)
setSelectedStrong(null)
}, [activeSessionId, sessions])
const selectedVerse = useMemo(
() =>
libraryVerses.find((verse) => verse.id === selectedId) ??
@@ -84,6 +240,10 @@ function App() {
selectedVerse.strongs[0]
)
}, [selectedStrong, selectedVerse])
const activeSession = useMemo(
() => sessions.find((session) => session.id === activeSessionId) ?? sessions[0],
[activeSessionId, sessions],
)
const pageAnchors = useMemo(() => {
const source = libraryVerses.length ? libraryVerses : verses
const anchors: Verse[] = []
@@ -150,6 +310,12 @@ function App() {
function searchStrong(strong: string) {
setQuery(strong)
setSelectedStrong(strong)
recordTimeline({
type: 'strong',
label: `Strong's ${strong}`,
reference: selectedVerse ? reference(selectedVerse) : undefined,
detail: 'Opened matching verses search',
})
runSearch(strong).catch((reason) => setError(String(reason)))
}
@@ -167,7 +333,17 @@ function App() {
setSelectedStrong(null)
setVerses(libraryVerses)
setSelectedId(next.id)
setPageStartId(findPageStartId(next))
const nextPageStartId = findPageStartId(next)
setPageStartId(nextPageStartId)
recordTimeline(
{
type: 'navigation',
label: reference(next),
reference: reference(next),
detail: 'Jumped from selector',
},
{ selectedId: next.id, pageStartId: nextPageStartId },
)
}
function shiftPage(direction: -1 | 1) {
@@ -183,6 +359,14 @@ function App() {
setPageStartId(next.id)
setSelectedId(next.id)
setSelectedStrong(null)
recordTimeline(
{
type: 'navigation',
label: direction > 0 ? 'Next page' : 'Previous page',
reference: reference(next),
},
{ selectedId: next.id, pageStartId: next.id },
)
}
function findPageStartId(verse: Verse) {
@@ -205,11 +389,81 @@ function App() {
function selectVerse(verse: Verse) {
setSelectedId(verse.id)
setSelectedStrong(null)
recordTimeline(
{
type: 'verse',
label: reference(verse),
reference: reference(verse),
},
{ selectedId: verse.id },
)
}
function selectStrong(verse: Verse, strong: string) {
setSelectedId(verse.id)
setSelectedStrong(strong)
recordTimeline(
{
type: 'strong',
label: strong,
reference: reference(verse),
},
{ selectedId: verse.id },
)
}
function setWorkspaceView(nextViewMode: ViewMode) {
setViewMode(nextViewMode)
recordTimeline(
{
type: 'view',
label: `View: ${nextViewMode}`,
},
{ viewMode: nextViewMode },
)
}
function setStrongDisplayMode(nextStrongDisplay: StrongDisplayMode) {
setStrongDisplay(nextStrongDisplay)
recordTimeline(
{
type: 'setting',
label: `Strong's display: ${nextStrongDisplay}`,
},
{ strongDisplay: nextStrongDisplay },
)
}
function updateNoteDraft(value: string) {
setNoteDraft(value)
patchActiveSession({ noteDraft: value })
}
function renameActiveSession(title: string) {
patchActiveSession({ title })
}
function startNewSession() {
const nextSession = createSession(`Study Session ${sessions.length + 1}`)
setSessions((current) => [nextSession, ...current])
setActiveSessionId(nextSession.id)
}
function restoreTimelineEntry(entry: TimelineEntry) {
if (!entry.reference) return
const [bookPart, rest] = entry.reference.split(/ (?=\d+:\d+$)/)
const [chapter, verse] = rest?.split(':').map(Number) ?? []
if (!bookPart || !chapter || !verse) return
jumpTo(bookPart, chapter, verse)
}
function recordFootnote(referenceText: string, text: string) {
recordTimeline({
type: 'footnote',
label: `Footnote ${referenceText}`,
reference: selectedVerse ? reference(selectedVerse) : undefined,
detail: text,
})
}
function renderVerseText(verse: Verse) {
@@ -273,22 +527,30 @@ function App() {
function renderOriginalLanguageLine() {
if (!selectedVerse?.strongs.length) return null
const isHebrew = selectedVerse.strongs.some((entry) => entry.strong.startsWith('H'))
const direction = isHebrew ? 'rtl' : 'ltr'
return (
<section className="original-language-verse" aria-label="Original language lemma line">
<h3>Lemma Line</h3>
<p>
<h3>{isHebrew ? 'Hebrew Lemma Line' : 'Greek Lemma Line'}</h3>
<p className="lemma-line-text" dir={direction}>
{selectedVerse.strongs.map((entry) => (
<button
<span
key={`${entry.strong}-${entry.position}-lemma`}
type="button"
className={entry.strong === selectedStrongEntry?.strong ? 'lemma-token active' : 'lemma-token'}
className={entry.strong === selectedStrongEntry?.strong ? 'lemma-word active' : 'lemma-word'}
role="button"
tabIndex={0}
title={`${entry.surface}: ${entry.strong}`}
onClick={() => setSelectedStrong(entry.strong)}
onClick={() => selectStrong(selectedVerse, entry.strong)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
selectStrong(selectedVerse, entry.strong)
}
}}
>
<span>{entry.lemma || entry.surface}</span>
<small>{entry.strong}</small>
</button>
{entry.lemma || entry.surface}
</span>
))}
</p>
</section>
@@ -346,7 +608,7 @@ function App() {
className={entry.strong === selectedStrongEntry?.strong ? 'word-link active' : 'word-link'}
type="button"
title={`${entry.surface}: ${entry.strong}`}
onClick={() => setSelectedStrong(entry.strong)}
onClick={() => selectStrong(selectedVerse, entry.strong)}
>
<span>{entry.surface}</span>
<small>{entry.strong}</small>
@@ -379,7 +641,18 @@ function App() {
{visibleFootnotes.length ? (
<div className="footnote-list">
{visibleFootnotes.map((note) => (
<p key={note.id}>
<p
key={note.id}
role="button"
tabIndex={0}
onClick={() => recordFootnote(note.reference, note.text)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
recordFootnote(note.reference, note.text)
}
}}
>
<span>{note.reference}</span>
{note.text}
</p>
@@ -396,10 +669,10 @@ function App() {
<main className="app-shell">
<aside className="tool-rail" aria-label="Main tools">
<span className="brand-mark">LS</span>
<button type="button" title="Bible page" onClick={() => setViewMode('page')}>B</button>
<button type="button" title="Study margin" onClick={() => setViewMode('study')}>M</button>
<button type="button" title="Notes workspace" onClick={() => setViewMode('notes')}>N</button>
<button type="button" title="Compare translations" onClick={() => setViewMode('compare')}>C</button>
<button type="button" title="Bible page" onClick={() => setWorkspaceView('page')}>B</button>
<button type="button" title="Study margin" onClick={() => setWorkspaceView('study')}>M</button>
<button type="button" title="Notes workspace" onClick={() => setWorkspaceView('notes')}>N</button>
<button type="button" title="Compare translations" onClick={() => setWorkspaceView('compare')}>C</button>
</aside>
<section className="reader">
@@ -473,7 +746,7 @@ function App() {
<select
value={strongDisplay}
onChange={(event) =>
setStrongDisplay(event.target.value as StrongDisplayMode)
setStrongDisplayMode(event.target.value as StrongDisplayMode)
}
>
<option value="off">Off</option>
@@ -533,9 +806,32 @@ function App() {
<span className="eyebrow">Notes</span>
<h2>Study Notes</h2>
</header>
<div className="session-manager" aria-label="Study session manager">
<label>
Session
<select
value={activeSessionId}
onChange={(event) => setActiveSessionId(event.target.value)}
>
{sessions.map((session) => (
<option key={session.id} value={session.id}>
{session.title}
</option>
))}
</select>
</label>
<input
value={activeSession?.title ?? ''}
onChange={(event) => renameActiveSession(event.target.value)}
placeholder="Session title"
/>
<button type="button" onClick={startNewSession}>
New
</button>
</div>
<textarea
value={noteDraft}
onChange={(event) => setNoteDraft(event.target.value)}
onChange={(event) => updateNoteDraft(event.target.value)}
placeholder="Write sermon notes, study observations, or drag clippings here later."
/>
<div className="tag-editor">
@@ -560,6 +856,31 @@ function App() {
</span>
))}
</div>
<section className="session-timeline" aria-label="Session timeline">
<header>
<span className="eyebrow">Timeline</span>
<strong>{activeSession?.timeline.length ?? 0} events</strong>
</header>
{activeSession?.timeline.length ? (
<ol>
{activeSession.timeline.slice(0, 14).map((entry) => (
<li key={entry.id}>
<button
type="button"
disabled={!entry.reference}
onClick={() => restoreTimelineEntry(entry)}
>
<span>{formatTimelineTime(entry.at)}</span>
<strong>{entry.label}</strong>
{entry.detail ? <small>{entry.detail}</small> : null}
</button>
</li>
))}
</ol>
) : (
<p>Click verses, Strong's entries, page controls, or footnotes to build this session timeline.</p>
)}
</section>
</aside>
) : null}
</div>