Add study completion, checkpoints, lesson announcements, and lesson-thread defaults

This commit is contained in:
nmemmert
2026-06-02 12:03:29 -04:00
parent 33ac2fb5d0
commit b18f4e88fa
4 changed files with 362 additions and 8 deletions
+198 -6
View File
@@ -46,6 +46,10 @@ type StudyAccountOverview = {
type StudyNotesMap = Record<string, string>
type StudyProgress = {
completedSectionIds: string[]
}
type StudyCommunityReply = {
id: string
authorName: string
@@ -700,6 +704,8 @@ export function ColossiansStudyIndexPage({ content }: Props) {
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [] })
const [enrolling, setEnrolling] = useState(false)
const [enrollMessage, setEnrollMessage] = useState('')
const [studyProgress, setStudyProgress] = useState<StudyProgress>({ completedSectionIds: [] })
const [progressLoading, setProgressLoading] = useState(false)
useEffect(() => {
let cancelled = false
@@ -742,6 +748,30 @@ export function ColossiansStudyIndexPage({ content }: Props) {
const populatedChapterCount = chapterSummaries.filter(chapter => chapter.lessonCount > 0).length
const enrolled = isEnrolledInStudy(auth, study.slug)
useEffect(() => {
if (!auth.checked || !auth.authenticated || !study || !enrolled) return
let cancelled = false
setProgressLoading(true)
readJson<{ studySlug: string; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}`)
.then(data => {
if (cancelled) return
setStudyProgress({ completedSectionIds: Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [] })
})
.catch(() => {
if (cancelled) return
setStudyProgress({ completedSectionIds: [] })
})
.finally(() => {
if (cancelled) return
setProgressLoading(false)
})
return () => {
cancelled = true
}
}, [auth.checked, auth.authenticated, study, enrolled])
async function enrollInStudy() {
if (!study) return
setEnrollMessage('')
@@ -773,6 +803,9 @@ export function ColossiansStudyIndexPage({ content }: Props) {
<span>{releasedSections.length} lessons available now</span>
<span>{chapterSummaries.length} chapters</span>
<span>{populatedChapterCount} chapters with lessons</span>
{enrolled && !progressLoading ? (
<span>{studyProgress.completedSectionIds.length} lessons completed</span>
) : null}
<span>Text + commentary + discussion</span>
<span>Student notes enabled</span>
</div>
@@ -848,6 +881,9 @@ export function ColossiansStudyIndexPage({ content }: Props) {
{isNew && <span style={{ backgroundColor: '#4caf50', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>New</span>}
{coming && <span style={{ backgroundColor: '#ffb74d', color: '#333', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Coming {getReleaseDateDisplay(section)}</span>}
{!coming && !enrolled && <span style={{ backgroundColor: '#ef5350', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Enroll to open</span>}
{enrolled && studyProgress.completedSectionIds.includes(section.id) && (
<span style={{ backgroundColor: '#2196f3', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Completed</span>
)}
</div>
<h3>{section.title}</h3>
<p>{section.summary}</p>
@@ -892,6 +928,12 @@ export function ColossiansStudySectionPage({ content }: Props) {
const [noteLoading, setNoteLoading] = useState(false)
const [noteSaving, setNoteSaving] = useState(false)
const [noteMessage, setNoteMessage] = useState('')
const [completedSectionIds, setCompletedSectionIds] = useState<string[]>([])
const [progressLoading, setProgressLoading] = useState(false)
const [progressSaving, setProgressSaving] = useState(false)
const [progressMessage, setProgressMessage] = useState('')
const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({})
const [checkpointReflection, setCheckpointReflection] = useState('')
const canSaveNote = auth.authenticated && !noteSaving && !noteLoading
const lessonAudioEmbedUrl = useMemo(() => {
@@ -903,6 +945,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
const currentStudySlug = study?.slug ?? 'colossians'
const noteId = section?.id ? getNoteId(currentStudySlug, section.id) : ''
const isEnrolled = isEnrolledInStudy(auth, currentStudySlug)
const lessonCompleted = section?.id ? completedSectionIds.includes(section.id) : false
useEffect(() => {
document.title = section && study ? `${section.title} | ${study.title}` : 'Study'
@@ -930,6 +973,36 @@ export function ColossiansStudySectionPage({ content }: Props) {
}
}, [])
useEffect(() => {
if (!auth.checked || !auth.authenticated || !study || !isEnrolled) return
let cancelled = false
setProgressLoading(true)
setProgressMessage('')
readJson<{ studySlug: string; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(currentStudySlug)}`)
.then(data => {
if (cancelled) return
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
})
.catch(() => {
if (cancelled) return
setCompletedSectionIds([])
})
.finally(() => {
if (cancelled) return
setProgressLoading(false)
})
return () => {
cancelled = true
}
}, [auth.checked, auth.authenticated, study, isEnrolled, currentStudySlug])
useEffect(() => {
setCheckpointAnswers({})
setCheckpointReflection('')
}, [section?.id])
useEffect(() => {
if (!auth.authenticated || !noteId || !isEnrolled) {
setNoteText('')
@@ -1020,6 +1093,70 @@ export function ColossiansStudySectionPage({ content }: Props) {
}
}
async function markLessonComplete() {
if (!study || !section?.id || progressSaving) return
setProgressSaving(true)
setProgressMessage('')
try {
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`, {
method: 'POST',
})
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
setProgressMessage('Lesson marked complete.')
} catch (err) {
setProgressMessage(err instanceof Error ? err.message : 'Unable to update completion.')
} finally {
setProgressSaving(false)
}
}
async function unmarkLessonCompletion() {
if (!study || !section?.id || progressSaving) return
setProgressSaving(true)
setProgressMessage('')
try {
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`, {
method: 'DELETE',
})
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
setProgressMessage('Lesson marked incomplete.')
} catch (err) {
setProgressMessage(err instanceof Error ? err.message : 'Unable to update completion.')
} finally {
setProgressSaving(false)
}
}
async function submitCheckpoint() {
if (!study || !section?.id || progressSaving) return
const checkpointQuestions = section.checkpointQuestions ?? []
const reflectionRequired = !(checkpointQuestions.length > 0)
const allAnswered = checkpointQuestions.every((_, index) => (checkpointAnswers[index] ?? '').trim().length > 0)
if (reflectionRequired && !checkpointReflection.trim()) {
setProgressMessage('Please write a short reflection before submitting the checkpoint.')
return
}
if (!reflectionRequired && !allAnswered) {
setProgressMessage('Please answer all checkpoint questions before submitting.')
return
}
setProgressSaving(true)
setProgressMessage('')
try {
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`, {
method: 'POST',
})
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
setProgressMessage('Checkpoint submitted and lesson marked complete.')
} catch (err) {
setProgressMessage(err instanceof Error ? err.message : 'Unable to submit checkpoint.')
} finally {
setProgressSaving(false)
}
}
async function enrollInCurrentStudy() {
if (!study) return
setEnrollMessage('')
@@ -1121,6 +1258,13 @@ export function ColossiansStudySectionPage({ content }: Props) {
<p className="study-detail-reference">{section.reference}</p>
<p className="study-detail-summary">{section.summary}</p>
{section.announcement && (
<article className="study-class-block" style={{ backgroundColor: '#1c1b17', border: '1px solid #3f3b2f' }}>
<h2>Lesson Announcement</h2>
<p className="study-detail-copy">{section.announcement}</p>
</article>
)}
{lessonAudioEmbedUrl && (
<article className="study-class-block">
<h2>Lesson Audio</h2>
@@ -1156,10 +1300,49 @@ export function ColossiansStudySectionPage({ content }: Props) {
))}
</ol>
<div style={{ marginTop: '1.25rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to={`/study/${study.slug}/community`} className="btn-primary">Go to Community</Link>
<Link to={`/study/${study.slug}/community?sectionId=${encodeURIComponent(section.id)}`} className="btn-primary">Go to Community</Link>
<Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
</div>
</article>
{(section.checkpointPrompt || (section.checkpointQuestions && section.checkpointQuestions.length > 0)) && (
<article className="study-class-block">
<h2>Checkpoint</h2>
{section.checkpointPrompt && <p className="study-detail-copy">{section.checkpointPrompt}</p>}
{section.checkpointQuestions && section.checkpointQuestions.length > 0 ? (
<div style={{ display: 'grid', gap: '1rem' }}>
{section.checkpointQuestions.map((question, index) => (
<div key={index}>
<p style={{ margin: '0 0 0.5rem', fontWeight: 600 }}>{question}</p>
<textarea
rows={3}
value={checkpointAnswers[index] ?? ''}
onChange={e => setCheckpointAnswers(prev => ({ ...prev, [index]: e.target.value }))}
placeholder="Write your answer here..."
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
/>
</div>
))}
</div>
) : (
<div>
<textarea
rows={4}
value={checkpointReflection}
onChange={e => setCheckpointReflection(e.target.value)}
placeholder="Reflect on the prompt above and write your observations here..."
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
/>
</div>
)}
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn-primary" onClick={submitCheckpoint} disabled={!isEnrolled || progressSaving}>
{progressSaving ? 'Submitting...' : lessonCompleted ? 'Re-submit Checkpoint' : 'Submit Checkpoint'}
</button>
{lessonCompleted && <span style={{ color: '#a39d8d' }}>Checkpoint completed for this lesson.</span>}
</div>
</article>
)}
</div>
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
@@ -1212,13 +1395,21 @@ export function ColossiansStudySectionPage({ content }: Props) {
placeholder="Write your lesson notes here..."
disabled={noteLoading || noteSaving}
/>
<div className="study-auth-actions">
<div className="study-auth-actions" style={{ flexWrap: 'wrap', gap: '0.5rem' }}>
<button type="button" className="btn-primary" disabled={!canSaveNote} onClick={saveNote}>{noteSaving ? 'Saving...' : 'Save Notes'}</button>
<button type="button" className="btn-secondary" disabled={authBusy} onClick={logoutStudyUser}>Sign Out</button>
<button
type="button"
className={lessonCompleted ? 'btn-secondary' : 'btn-primary'}
disabled={!isEnrolled || progressSaving || progressLoading}
onClick={lessonCompleted ? unmarkLessonCompletion : markLessonComplete}
>
{progressSaving ? 'Updating...' : lessonCompleted ? 'Mark Incomplete' : 'Mark Complete'}
</button>
</div>
</div>
)}
{(authMessage || noteMessage) && <p className="study-note-status">{authMessage || noteMessage}</p>}
{(authMessage || noteMessage || progressMessage) && <p className="study-note-status">{authMessage || noteMessage || progressMessage}</p>}
</article>
<article className="study-class-side-block">
@@ -1698,7 +1889,7 @@ export function StudyAccountPage() {
<p style={{ margin: 0, fontWeight: 600 }}>{study.title}</p>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.84rem' }}>
Status: <strong>{study.enrolled ? 'Enrolled' : 'Not Enrolled'}</strong>
{` • Progress: ${study.completedLessons}/${study.totalLessons || 0} lessons with notes`}
{` • Progress: ${study.completedLessons}/${study.totalLessons || 0} lessons completed`}
{` • Notes: ${study.noteCount}`}
</p>
</div>
@@ -1847,8 +2038,10 @@ export function StudyAccountPage() {
export function StudyCommunityPage({ content }: Props) {
const { studySlug } = useParams<{ studySlug: string }>()
const location = useLocation()
const studies = getStudies(content)
const study = getStudyBySlug(studies, studySlug)
const defaultSectionId = new URLSearchParams(location.search).get('sectionId') ?? ''
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '' })
@@ -1889,9 +2082,8 @@ export function StudyCommunityPage({ content }: Props) {
</div>
</div>
</section>
<section className="section-inner" style={{ paddingTop: '2rem', paddingBottom: '4rem', maxWidth: '860px' }}>
<CommunityBoard study={study} auth={auth} isEnrolled={isEnrolled} />
<CommunityBoard study={study} auth={auth} isEnrolled={isEnrolled} sectionFilter={defaultSectionId} />
</section>
</main>
)