Two concurrent series lanes, Complete studies, self-paced finished books; v2.9.0
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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-08 10:14:02 -04:00
1 parent 2d8e4be5ea
commit ddcaec0ddf
12 files changed
+628 -105

No files matched your search

+80 -11
View File
@@ -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 (