Two concurrent series lanes, Complete studies, self-paced finished books; v2.9.0
Publish Container / build (push) Successful in 17m50s
Publish Container / build (push) Successful in 17m50s
- Study status 'complete' (manual or auto when all lessons released); stays enrollable and self-paced (no sequential lock, Resume button, Finished Studies hub section) - Studies get lane (A/B) and expected start date; planned studies show "Starts <date>"; hub shows next lesson date instead of hardcoded pace - Second concurrent series (homepage card, admin panel) with its own scheduled launch; transitions are per lane and flip the ending study to Complete and the incoming study to Active - Finished books link to their study: "Study This Book" on playlist page, audio-only link from hub (playlist stays public, no account) - Admin: bulk release scheduler (every N days), same-day collision warning, linked-study selects - Weekly digest no longer dumps a finished study's backlog on late enrollees - CI: build and tag the container as :beta on pushes to the beta branch Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2d8e4be5ea
commit
ddcaec0ddf
12 files changed
+628
-105
No files matched your search
+80
-11
@@ -64,7 +64,7 @@ type StudyAccountOverview = {
|
||||
studies: Array<{
|
||||
slug: string
|
||||
title: string
|
||||
status: 'active' | 'planned'
|
||||
status: 'active' | 'planned' | 'complete'
|
||||
enrolled: boolean
|
||||
totalLessons: number
|
||||
completedLessons: number
|
||||
@@ -107,8 +107,9 @@ type StudyCommunityPost = {
|
||||
pending?: boolean
|
||||
}
|
||||
|
||||
function StudyStatusBadge({ status }: { status: 'active' | 'planned' }) {
|
||||
return <span className={`study-status-badge ${status}`}>{status === 'active' ? 'Active' : 'Planned'}</span>
|
||||
function StudyStatusBadge({ status }: { status: 'active' | 'planned' | 'complete' }) {
|
||||
const label = status === 'active' ? 'Active' : status === 'complete' ? 'Complete' : 'Planned'
|
||||
return <span className={`study-status-badge ${status}`}>{label}</span>
|
||||
}
|
||||
|
||||
function getLegacyColossiansStudy(content: SiteContent): StudyProgram {
|
||||
@@ -200,6 +201,24 @@ function isSectionReleased(section: StudySection): boolean {
|
||||
return releaseDate <= new Date()
|
||||
}
|
||||
|
||||
// A study is complete when an admin marks it so, or when every lesson has been released.
|
||||
// Complete studies stay open for enrollment and are fully self-paced.
|
||||
function getEffectiveStudyStatus(study: StudyProgram): 'active' | 'planned' | 'complete' {
|
||||
if (study.status === 'planned') return 'planned'
|
||||
if (study.status === 'complete') return 'complete'
|
||||
if (study.sections.length > 0 && study.sections.every(isSectionReleased)) return 'complete'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
function getNextReleaseLabel(study: StudyProgram): string {
|
||||
const upcoming = study.sections
|
||||
.map(getSectionReleaseDate)
|
||||
.filter((date): date is Date => date !== null && date > new Date())
|
||||
.sort((a, b) => a.getTime() - b.getTime())[0]
|
||||
if (!upcoming) return ''
|
||||
return upcoming.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function getReleaseDateDisplay(section: StudySection): string {
|
||||
const date = getSectionReleaseDate(section)
|
||||
if (!date) return ''
|
||||
@@ -589,9 +608,10 @@ export function StudyLandingPage({ content }: Props) {
|
||||
const [newsletterNudgeDone, setNewsletterNudgeDone] = useState(false)
|
||||
|
||||
const studies = getStudies(content)
|
||||
const activeStudies = studies.filter(study => study.status !== 'planned')
|
||||
const plannedStudies = studies.filter(study => study.status === 'planned')
|
||||
const firstActiveStudy = activeStudies[0]
|
||||
const activeStudies = studies.filter(study => getEffectiveStudyStatus(study) === 'active')
|
||||
const completeStudies = studies.filter(study => getEffectiveStudyStatus(study) === 'complete')
|
||||
const plannedStudies = studies.filter(study => getEffectiveStudyStatus(study) === 'planned')
|
||||
const firstActiveStudy = activeStudies[0] ?? completeStudies[0]
|
||||
const firstActiveStudyFirstReleasedSection = firstActiveStudy?.sections.find(isSectionReleased)
|
||||
|
||||
async function refreshOverview() {
|
||||
@@ -929,7 +949,7 @@ export function StudyLandingPage({ content }: Props) {
|
||||
<h1>Study at Your Own Pace</h1>
|
||||
<p className="study-course-hero-copy">Pick a study track, move lesson by lesson on your own schedule, and keep personal notes as you grow through each passage.</p>
|
||||
<div className="study-course-meta">
|
||||
<span>{activeStudies.length} {activeStudies.length === 1 ? 'study' : 'studies'} available{plannedStudies.length > 0 ? ` · ${plannedStudies.length} coming soon` : ''}</span>
|
||||
<span>{activeStudies.length + completeStudies.length} {activeStudies.length + completeStudies.length === 1 ? 'study' : 'studies'} available{completeStudies.length > 0 ? ` (${completeStudies.length} finished, start anytime)` : ''}{plannedStudies.length > 0 ? ` · ${plannedStudies.length} coming soon` : ''}</span>
|
||||
<span>Self-paced flow</span>
|
||||
<span>Personal notes</span>
|
||||
<span>Audio + commentary</span>
|
||||
@@ -992,7 +1012,11 @@ export function StudyLandingPage({ content }: Props) {
|
||||
<div className="study-module-row-right">
|
||||
<p className="study-module-focus">Track Snapshot</p>
|
||||
<p>{releasedCount > 0 ? `${releasedCount} lessons available now` : 'Lessons will be published soon.'}</p>
|
||||
<p>{study.sections.length > 0 ? 'Estimated pace: every other Monday' : 'Pacing details coming with first lesson release.'}</p>
|
||||
<p>{(() => {
|
||||
const next = getNextReleaseLabel(study)
|
||||
if (next) return `Next lesson: ${next}`
|
||||
return study.sections.length > 0 ? 'New lessons release on a regular schedule.' : 'Pacing details coming with first lesson release.'
|
||||
})()}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
@@ -1002,6 +1026,43 @@ export function StudyLandingPage({ content }: Props) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{completeStudies.length > 0 && (
|
||||
<section className="section-study-module" aria-label="Finished studies" id="finished-studies">
|
||||
<div className="section-inner">
|
||||
<div className="study-module-header">
|
||||
<p>Study On Your Own Time</p>
|
||||
<h2>Finished Studies</h2>
|
||||
</div>
|
||||
<div className="study-module-list">
|
||||
{completeStudies.map(study => {
|
||||
const book = (content.finishedBooks ?? []).find(item => item.studySlug === study.slug)
|
||||
return (
|
||||
<div key={study.id}>
|
||||
<Link to={`/study/${study.slug}`} className="study-module-row">
|
||||
<div className="study-module-row-left">
|
||||
<p className="study-module-lesson">Complete · Start Anytime</p>
|
||||
<h3>{study.title}</h3>
|
||||
<p>{study.description}</p>
|
||||
</div>
|
||||
<div className="study-module-row-right">
|
||||
<p className="study-module-focus">Self-Paced</p>
|
||||
<p>All {study.sections.length} lessons available now</p>
|
||||
<p>No schedule — go as fast or slow as you like.</p>
|
||||
</div>
|
||||
</Link>
|
||||
{book && (
|
||||
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>
|
||||
Just want to listen? <Link to={`/finished/${book.id}`}>Open the audio playlist — no account needed →</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{plannedStudies.length > 0 && (
|
||||
<section className="section-study-module" aria-label="Upcoming studies">
|
||||
<div className="section-inner">
|
||||
@@ -1019,7 +1080,9 @@ export function StudyLandingPage({ content }: Props) {
|
||||
</div>
|
||||
<div className="study-module-row-right">
|
||||
<p className="study-module-focus">Status</p>
|
||||
<p>Preparing lesson structure and media.</p>
|
||||
<p>{study.startsAt && new Date(study.startsAt).getTime() > Date.now()
|
||||
? `Starts ${new Date(study.startsAt).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}`
|
||||
: 'Preparing lesson structure and media.'}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -1567,6 +1630,11 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
const chapterSummaries = getChapterSummaries(study)
|
||||
const releasedSections = sections.filter(isSectionReleased)
|
||||
const firstReleasedSection = releasedSections[0]
|
||||
const studyStatus = getEffectiveStudyStatus(study)
|
||||
const isSelfPaced = studyStatus === 'complete'
|
||||
// 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 populatedChapterCount = chapterSummaries.filter(chapter => chapter.lessonCount > 0).length
|
||||
|
||||
async function enrollInStudy() {
|
||||
@@ -1606,9 +1674,10 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
) : null}
|
||||
<span>Text + commentary + discussion</span>
|
||||
<span>Student notes enabled</span>
|
||||
{isSelfPaced && <span>Finished study — go at your own pace</span>}
|
||||
</div>
|
||||
<div className="study-course-hero-actions">
|
||||
{enrolled && firstReleasedSection && <Link to={`/study/${study.slug}/${firstReleasedSection.id}`} className="btn-primary">Start Class</Link>}
|
||||
{enrolled && resumeSection && <Link to={`/study/${study.slug}/${resumeSection.id}`} className="btn-primary">{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}>
|
||||
@@ -1671,7 +1740,7 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
const coming = isComingSoon(section)
|
||||
const prevSection = sectionIdx > 0 ? sections[sectionIdx - 1] : null
|
||||
const prevCompleted = !prevSection || studyProgress.completedSectionIds.includes(prevSection.id)
|
||||
const lockedByProgress = enrolled && !coming && sectionIdx > 0 && !prevCompleted && !studyProgress.completedSectionIds.includes(section.id)
|
||||
const lockedByProgress = enrolled && !isSelfPaced && !coming && sectionIdx > 0 && !prevCompleted && !studyProgress.completedSectionIds.includes(section.id)
|
||||
const isDisabled = coming || !enrolled || lockedByProgress
|
||||
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user