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
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:
1 parent
7301982bb1
commit
bb83f3e8c5
13 files changed
+221
-52
No files matched your search
+13
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user