Completed studies: finishing a study keeps access after leaving; certificates always shown; no reminders for finished studies; v2.11.0
Publish Container / build (push) Successful in 15m31s

- Completion is stored on the user (completedStudySlugs, sticky) when every lesson is done; backfilled at startup
- Lessons, notes, progress, quiz, community and certificate stay open for completed studies after leaving the enrollment
- Dashboard/My Account list completed studies with Review Lessons and certificate access
- Study page shows a completed banner
- Lesson-release reminders, weekly digest and re-engagement emails skip finished studies
- Fix: streak fields (current/longest/last studied) and firstCompletionAt were dropped on every restart when users were loaded
- Tests for completion/access/re-engagement

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-09 11:42:58 -04:00
1 parent 7301982bb1
commit bb83f3e8c5
13 files changed
+221 -52

No files matched your search

+13
View File
@@ -4541,6 +4541,19 @@
.account-cert-study .study-cert-heading { display: none; }
.account-cert-study .study-cert-canvas { width: 100%; height: auto; max-width: 100%; }
.account-chip--inline { margin-left: 0.5rem; vertical-align: middle; }
.study-completed-banner {
max-width: 560px;
margin: 0 0 1.25rem;
padding: 0.75rem 1rem;
border: 1px solid rgba(111, 207, 151, 0.4);
border-radius: 12px;
background: rgba(111, 207, 151, 0.08);
color: #b9e2c9;
font-size: 0.95rem;
}
/* ── Study hub (guest landing) ── */
.study-hub-count {
margin: 1.25rem 0 0;
+26 -10
View File
@@ -67,6 +67,7 @@ type StudyAccountOverview = {
title: string
status: 'active' | 'planned' | 'complete'
enrolled: boolean
completed?: boolean
totalLessons: number
completedLessons: number
noteCount: number
@@ -703,10 +704,14 @@ export function StudyLandingPage({ content }: Props) {
}, [])
const enrolledStudies = overview?.studies.filter(study => study.enrolled) ?? []
// Finished studies stay in the classroom (with their certificate) even if the student left the enrollment
const classroomStudies = [...(overview?.studies.filter(study => study.enrolled || study.completed) ?? [])]
.sort((a, b) => Number(Boolean(a.completed)) - Number(Boolean(b.completed)))
const completedStudyCount = classroomStudies.filter(study => study.completed).length
const availableStudies = [...(overview?.studies ?? [])].sort((a, b) => Number(b.status === 'active') - Number(a.status === 'active'))
const totalEnrolled = enrolledStudies.length
const totalCompletedLessons = enrolledStudies.reduce((sum, study) => sum + study.completedLessons, 0)
const totalLessons = enrolledStudies.reduce((sum, study) => sum + study.totalLessons, 0)
const totalCompletedLessons = classroomStudies.reduce((sum, study) => sum + study.completedLessons, 0)
const totalLessons = classroomStudies.reduce((sum, study) => sum + study.totalLessons, 0)
if (loading) {
return (
@@ -744,6 +749,7 @@ export function StudyLandingPage({ content }: Props) {
<div className="study-course-meta" style={{ marginTop: '1.5rem', gap: '0.75rem' }}>
<span>{totalEnrolled} enrolled track{totalEnrolled === 1 ? '' : 's'}</span>
{completedStudyCount > 0 && <span>{completedStudyCount} completed</span>}
<span>{totalCompletedLessons}/{totalLessons} lessons completed</span>
<span>{overview.stats.noteCount} notes saved</span>
{auth.studyRemindersEnabled ? <span>Lesson reminders enabled</span> : <span>Reminders off</span>}
@@ -778,6 +784,7 @@ export function StudyLandingPage({ content }: Props) {
<StudyStatusBadge status={studyInfo.status} />
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
{studyInfo.completed && <p style={{ margin: '0.4rem 0 0', fontSize: '0.85rem', color: '#6fcf97' }}>✓ Completed — leaving only stops reminders. You keep your lessons, notes and certificate.</p>}
</div>
{confirmLeaveSlug === studyInfo.slug ? (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
@@ -819,6 +826,7 @@ export function StudyLandingPage({ content }: Props) {
<StudyStatusBadge status={studyInfo.status} />
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
{!studyInfo.enrolled && studyInfo.status === 'planned' ? <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem', color: '#b09e79' }}>Not open for enrollment yet</p> : <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>}
{studyInfo.completed && <p style={{ margin: '0.4rem 0 0', fontSize: '0.85rem', color: '#6fcf97' }}>✓ Completed — you keep your lessons, notes and certificate.</p>}
</div>
{studyInfo.enrolled && confirmLeaveSlug === studyInfo.slug ? (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
@@ -886,10 +894,10 @@ export function StudyLandingPage({ content }: Props) {
<div className="section-inner">
<div className="study-module-header">
<p>Your Classroom</p>
<h2>Enrolled Tracks</h2>
<h2>Your Tracks</h2>
</div>
{totalEnrolled === 0 ? (
{classroomStudies.length === 0 ? (
<article className="study-class-block">
<h2>No enrollments yet</h2>
<p className="study-detail-copy">You are signed in, but you have not enrolled in any study track yet. Browse the available tracks and join the one you want to start.</p>
@@ -897,14 +905,14 @@ export function StudyLandingPage({ content }: Props) {
</article>
) : (
<div className="study-module-list">
{enrolledStudies.map(study => {
{classroomStudies.map(study => {
const studyMeta = getStudyBySlug(studies, study.slug)
const progress = study.totalLessons > 0 ? Math.round((study.completedLessons / study.totalLessons) * 100) : 0
return (
<div key={study.slug}>
<article className="study-module-row">
<div className="study-module-row-left">
<p className="study-module-lesson">{study.enrolled ? 'Enrolled' : 'Track'}</p>
<p className="study-module-lesson">{study.completed ? '✓ Completed' : study.enrolled ? 'Enrolled' : 'Track'}</p>
<h3>{study.title}</h3>
<p>{studyMeta?.description ?? 'Continue your study with the lessons and notes available in this track.'}</p>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
@@ -921,9 +929,10 @@ export function StudyLandingPage({ content }: Props) {
<p>{progress}% complete</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '1rem' }}>
<Link to={`/study/${study.slug}`} className="btn-primary">Continue</Link>
<Link to={`/study/${study.slug}`} className="btn-primary">{study.completed ? 'Review Lessons' : 'Continue'}</Link>
<Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>
<Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>
{study.completed && <Link to="/study/account" className="btn-secondary">🏅 Certificate</Link>}
</div>
</div>
</article>
@@ -1621,6 +1630,7 @@ export function ColossiansStudyIndexPage({ content }: Props) {
// Resume at the first released lesson the student hasn't finished yet
const resumeSection = releasedSections.find(item => !studyProgress.completedSectionIds.includes(item.id)) ?? firstReleasedSection
const hasStarted = enrolled && studyProgress.completedSectionIds.length > 0
const allLessonsDone = enrolled && !progressLoading && sections.length > 0 && sections.every(item => studyProgress.completedSectionIds.includes(item.id))
const resumeChapter = resumeSection?.chapter ?? chapterSummaries.find(chapter => chapter.lessonCount > 0)?.chapter
const completedCount = sections.filter(item => studyProgress.completedSectionIds.includes(item.id)).length
const chaptersWithLessons = chapterSummaries.filter(chapter => chapter.lessonCount > 0)
@@ -1659,6 +1669,9 @@ export function ColossiansStudyIndexPage({ content }: Props) {
<span>Student notes enabled</span>
{isSelfPaced && <span>Finished study — go at your own pace</span>}
</div>
{allLessonsDone && (
<p className="study-completed-banner" role="status">🎉 You've completed this study. Your lessons, notes and certificate are yours to keep.</p>
)}
{enrolled && !progressLoading && sections.length > 0 && (
<div className="study-progress" role="group" aria-label="Your progress">
<div className="study-progress-label">
@@ -1671,13 +1684,14 @@ export function ColossiansStudyIndexPage({ content }: Props) {
</div>
)}
<div className="study-course-hero-actions">
{enrolled && resumeSection && <Link to={`/study/${study.slug}/${resumeSection.id}`} className="btn-primary">{hasStarted ? 'Resume Where You Left Off' : 'Start Class'}</Link>}
{enrolled && resumeSection && <Link to={`/study/${study.slug}/${resumeSection.id}`} className="btn-primary">{allLessonsDone ? 'Review Lessons' : hasStarted ? 'Resume Where You Left Off' : 'Start Class'}</Link>}
{!auth.authenticated && <Link to="/study/signup" className="btn-primary">Sign In to Enroll</Link>}
{auth.authenticated && !enrolled && (
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
{enrolling ? 'Enrolling...' : 'Enroll in This Study'}
</button>
)}
{allLessonsDone && <Link to="/study/account" className="btn-secondary">🏅 My Certificate</Link>}
{enrolled && <Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>}
{enrolled && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
{auth.authenticated && <Link to="/study/account" className="btn-secondary">My Account</Link>}
@@ -3219,7 +3233,8 @@ export function StudyAccountPage() {
)
}
const enrolledProgress = overview?.studies.filter(item => item.enrolled && item.totalLessons > 0) ?? []
// Finished studies stay listed (with their certificate) even after the student leaves the enrollment
const enrolledProgress = overview?.studies.filter(item => (item.enrolled || item.completed) && item.totalLessons > 0) ?? []
const memberSince = overview?.stats.memberSince && !isNaN(new Date(overview.stats.memberSince).getTime())
? new Date(overview.stats.memberSince).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—'
@@ -3288,7 +3303,7 @@ export function StudyAccountPage() {
return (
<div className="account-progress" key={item.slug}>
<div className="account-progress-row">
<Link to={`/study/${item.slug}`}>{item.title}</Link>
<Link to={`/study/${item.slug}`}>{item.title}{item.completed && <span className="account-chip account-chip--ok account-chip--inline">Completed</span>}</Link>
<span>{item.completedLessons}/{item.totalLessons} lessons · {pct}%</span>
</div>
<div className="study-progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}>
@@ -3464,6 +3479,7 @@ export function StudyAccountPage() {
<StudyStatusBadge status={studyInfo.status} />
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
{!studyInfo.enrolled && studyInfo.status === 'planned' ? <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem', color: '#b09e79' }}>Not open for enrollment yet</p> : <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons} of {studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>}
{studyInfo.completed && <p style={{ margin: '0.4rem 0 0', fontSize: '0.85rem', color: '#6fcf97' }}>✓ Completed — leaving only stops reminders. You keep your lessons, notes and certificate.</p>}
</div>
{!studyInfo.enrolled && studyInfo.status === 'planned' ? (
<span style={{ fontSize: '0.85rem', color: '#b09e79' }}>Coming soon</span>