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
+26
-1
@@ -290,6 +290,28 @@ function sanitizeColossiansStudySections(value) {
|
||||
.filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText || item.checkpointPrompt || item.checkpointQuestions.length > 0)
|
||||
}
|
||||
|
||||
function sanitizeIsoDate(value) {
|
||||
if (typeof value !== 'string' || !value.trim()) return ''
|
||||
const time = Date.parse(value)
|
||||
return Number.isFinite(time) ? new Date(time).toISOString() : ''
|
||||
}
|
||||
|
||||
function sanitizeSecondSeries(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||
const str = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
|
||||
const season = Number.isInteger(value.season) && value.season >= 1 && value.season <= 999 ? value.season : undefined
|
||||
return {
|
||||
label: str(value.label, 80) || 'Now Playing',
|
||||
title: str(value.title, 160),
|
||||
description: str(value.description, 800),
|
||||
imageUrl: str(value.imageUrl, 500),
|
||||
listenUrl: str(value.listenUrl, 500),
|
||||
listenBtnLabel: str(value.listenBtnLabel, 80),
|
||||
studySlug: str(value.studySlug, 120).toLowerCase().replace(/[^a-z0-9-]/g, '-'),
|
||||
...(season ? { season } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeStudies(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
@@ -303,7 +325,9 @@ function sanitizeStudies(value) {
|
||||
showOnHomepage: item.showOnHomepage === true,
|
||||
showNewTag: item.showNewTag === true,
|
||||
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
|
||||
status: item.status === 'planned' ? 'planned' : 'active',
|
||||
status: item.status === 'planned' ? 'planned' : (item.status === 'complete' ? 'complete' : 'active'),
|
||||
lane: item.lane === 'A' || item.lane === 'B' ? item.lane : '',
|
||||
startsAt: sanitizeIsoDate(item.startsAt),
|
||||
difficulty: item.difficulty === 'advanced' ? 'advanced' : (item.difficulty === 'intermediate' ? 'intermediate' : 'beginner'),
|
||||
estimatedHours: Number.isFinite(Number(item.estimatedHours)) ? Math.max(1, Math.min(500, Number(item.estimatedHours))) : 8,
|
||||
completionBadge: typeof item.completionBadge === 'string' ? item.completionBadge.trim().slice(0, 120) : '',
|
||||
@@ -384,6 +408,7 @@ export function sanitizeSiteContent(siteContent) {
|
||||
customBlocks: sanitizeCustomBlocks(siteContent.customBlocks),
|
||||
archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries),
|
||||
studies: sanitizeStudies(siteContent.studies),
|
||||
secondSeries: sanitizeSecondSeries(siteContent.secondSeries),
|
||||
colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections),
|
||||
redirects: sanitizeRedirectRules(siteContent.redirects),
|
||||
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
|
||||
|
||||
+107
-32
@@ -53,15 +53,45 @@ function invokeWebhook(url, action) {
|
||||
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
|
||||
}
|
||||
|
||||
// Builds the content that results from applying a scheduled series transition.
|
||||
function applySeriesTransition(content, sched) {
|
||||
const archivedBook = {
|
||||
id: Date.now().toString(36),
|
||||
title: content.seriesTitle ?? '',
|
||||
description: content.seriesDescription ?? '',
|
||||
imageUrl: content.seriesImageUrl ?? '',
|
||||
season: (sched.newSeason ?? 2) - 1 || 1,
|
||||
// The site can run two series at once ("lanes"): the primary series (flat series* fields)
|
||||
// and an optional second series (secondSeries). Each lane has its own scheduled transition.
|
||||
const SERIES_LANES = [
|
||||
{ lane: 'primary', scheduleKey: 'scheduledSeriesTransition' },
|
||||
{ lane: 'second', scheduleKey: 'scheduledSecondSeriesTransition' },
|
||||
]
|
||||
|
||||
function readCurrentSeries(content, lane) {
|
||||
if (lane === 'second') {
|
||||
const s = content.secondSeries ?? {}
|
||||
return {
|
||||
title: s.title ?? '', description: s.description ?? '', imageUrl: s.imageUrl ?? '',
|
||||
season: s.season, studySlug: s.studySlug ?? '',
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: content.seriesTitle ?? '', description: content.seriesDescription ?? '', imageUrl: content.seriesImageUrl ?? '',
|
||||
season: content.seriesSeason, studySlug: content.seriesStudySlug ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the content that results from applying one lane's scheduled series transition.
|
||||
// Only the named lane changes; the other lane's series, studies and highlights are left alone.
|
||||
function applySeriesTransition(content, sched, lane = 'primary') {
|
||||
const scheduleKey = lane === 'second' ? 'scheduledSecondSeriesTransition' : 'scheduledSeriesTransition'
|
||||
const current = readCurrentSeries(content, lane)
|
||||
const newSeason = Number.isInteger(sched.newSeason) ? sched.newSeason : undefined
|
||||
const newStudySlug = typeof sched.newStudySlug === 'string' ? sched.newStudySlug.trim().toLowerCase() : ''
|
||||
|
||||
const archivedBook = current.title.trim()
|
||||
? {
|
||||
id: Date.now().toString(36),
|
||||
title: current.title,
|
||||
description: current.description,
|
||||
imageUrl: current.imageUrl,
|
||||
season: current.season ?? ((newSeason ?? 2) - 1 || 1),
|
||||
...(current.studySlug ? { studySlug: current.studySlug } : {}),
|
||||
}
|
||||
: null
|
||||
|
||||
const patch = {}
|
||||
const copy = (from, to) => {
|
||||
@@ -82,55 +112,100 @@ function applySeriesTransition(content, sched) {
|
||||
copy('newStudyGuideAppleBooksUrl', 'studyGuideAppleBooksUrl')
|
||||
copy('newStudyGuideAppleBooksLabel', 'studyGuideAppleBooksLabel')
|
||||
|
||||
// The finished series' study becomes Complete (still enrollable, self-paced); the
|
||||
// incoming series' study opens up, taking the lane it launches in.
|
||||
const laneLetter = lane === 'second' ? 'B' : 'A'
|
||||
const studies = (Array.isArray(content.studies) ? content.studies : []).map(study => {
|
||||
if (!study) return study
|
||||
if (current.studySlug && study.slug === current.studySlug && study.status !== 'planned') {
|
||||
return { ...study, status: 'complete' }
|
||||
}
|
||||
if (newStudySlug && study.slug === newStudySlug) {
|
||||
return { ...study, status: 'active', lane: study.lane || laneLetter, startsAt: '' }
|
||||
}
|
||||
return study
|
||||
})
|
||||
|
||||
const hasOtherLane = lane === 'second'
|
||||
? Boolean(content.seriesTitle?.trim())
|
||||
: Boolean(content.secondSeries?.title?.trim())
|
||||
|
||||
const lanePatch = lane === 'second'
|
||||
? {
|
||||
secondSeries: {
|
||||
...(content.secondSeries ?? {}),
|
||||
label: 'Now Playing',
|
||||
title: sched.newTitle.trim(),
|
||||
description: sched.newDescription?.trim() ?? '',
|
||||
imageUrl: sched.newImageUrl?.trim() || content.secondSeries?.imageUrl || '',
|
||||
studySlug: newStudySlug,
|
||||
...(newSeason ? { season: newSeason } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
seriesTitle: sched.newTitle.trim(),
|
||||
seriesDescription: sched.newDescription?.trim() ?? '',
|
||||
seriesImageUrl: sched.newImageUrl?.trim() || content.seriesImageUrl || '',
|
||||
seriesLabel: 'Now Playing',
|
||||
seriesStudySlug: newStudySlug,
|
||||
...(newSeason ? { seriesSeason: newSeason } : {}),
|
||||
}
|
||||
|
||||
const next = sanitizeSiteContent({
|
||||
...content,
|
||||
seriesTitle: sched.newTitle.trim(),
|
||||
seriesDescription: sched.newDescription?.trim() ?? '',
|
||||
seriesImageUrl: sched.newImageUrl?.trim() || content.seriesImageUrl || '',
|
||||
seriesLabel: 'Now Playing',
|
||||
podcastFeaturedLinks: [],
|
||||
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), archivedBook],
|
||||
...lanePatch,
|
||||
studies,
|
||||
// Highlights belong to whatever is playing now; don't wipe them while the other lane is still running.
|
||||
podcastFeaturedLinks: hasOtherLane ? content.podcastFeaturedLinks : [],
|
||||
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), ...(archivedBook ? [archivedBook] : [])],
|
||||
...patch,
|
||||
})
|
||||
delete next.scheduledSeriesTransition
|
||||
delete next[scheduleKey]
|
||||
return { next, archivedBook }
|
||||
}
|
||||
|
||||
// Applies a scheduled series transition if its triggersAt time has passed.
|
||||
// The schedule is saved with the draft (Save Schedule), so look in the draft as
|
||||
// well as published content. Applies to both so a later publish doesn't undo it.
|
||||
// Applies any scheduled series transitions (one per lane) whose triggersAt time has passed.
|
||||
// A schedule is saved with the draft (Save Schedule), so look in the draft as well as
|
||||
// published content. Applies to both so a later publish doesn't undo it.
|
||||
// Called on a server interval (see server.js). Safe to call if no transition is set.
|
||||
export async function runScheduledSeriesTransition() {
|
||||
const published = state.cachedSiteContent
|
||||
const draft = state.cachedDraftSiteContent
|
||||
if (!published) return
|
||||
|
||||
const sched = draft?.scheduledSeriesTransition ?? published.scheduledSeriesTransition
|
||||
if (!sched || !sched.triggersAt || !sched.newTitle?.trim()) return
|
||||
const triggerTime = new Date(sched.triggersAt).getTime()
|
||||
if (Number.isNaN(triggerTime) || triggerTime > Date.now()) return
|
||||
let nextPublished = published
|
||||
let nextDraft = draft
|
||||
const fired = []
|
||||
for (const { lane, scheduleKey } of SERIES_LANES) {
|
||||
const sched = nextDraft?.[scheduleKey] ?? nextPublished[scheduleKey]
|
||||
if (!sched || !sched.triggersAt || !sched.newTitle?.trim()) continue
|
||||
const triggerTime = new Date(sched.triggersAt).getTime()
|
||||
if (Number.isNaN(triggerTime) || triggerTime > Date.now()) continue
|
||||
|
||||
console.log('[series-scheduler] Firing scheduled series transition to:', sched.newTitle)
|
||||
|
||||
const { next: updatedContent, archivedBook } = applySeriesTransition(published, sched)
|
||||
console.log(`[series-scheduler] Firing scheduled ${lane} series transition to:`, sched.newTitle)
|
||||
const result = applySeriesTransition(nextPublished, sched, lane)
|
||||
nextPublished = result.next
|
||||
if (nextDraft) nextDraft = applySeriesTransition(nextDraft, sched, lane).next
|
||||
fired.push(result.archivedBook?.title ?? '(nothing archived)')
|
||||
}
|
||||
if (fired.length === 0) return
|
||||
|
||||
const publishedAt = new Date().toISOString()
|
||||
try {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(DATA_FILE, JSON.stringify({ siteContent: updatedContent, updatedAt: publishedAt }, null, 2), 'utf8')
|
||||
state.cachedSiteContent = updatedContent
|
||||
await writeFile(DATA_FILE, JSON.stringify({ siteContent: nextPublished, updatedAt: publishedAt }, null, 2), 'utf8')
|
||||
state.cachedSiteContent = nextPublished
|
||||
state.publishState.publishedAt = publishedAt
|
||||
console.log('[series-scheduler] Transition complete — archived:', archivedBook.title)
|
||||
console.log('[series-scheduler] Transition complete — archived:', fired.join(', '))
|
||||
} catch (err) {
|
||||
console.error('[series-scheduler] Failed to write transition:', err)
|
||||
return
|
||||
}
|
||||
|
||||
if (draft) {
|
||||
if (nextDraft && nextDraft !== draft) {
|
||||
try {
|
||||
const { next: updatedDraft } = applySeriesTransition(draft, sched)
|
||||
await writeFile(DRAFT_DATA_FILE, JSON.stringify({ siteContent: updatedDraft, updatedAt: publishedAt }, null, 2), 'utf8')
|
||||
state.cachedDraftSiteContent = updatedDraft
|
||||
await writeFile(DRAFT_DATA_FILE, JSON.stringify({ siteContent: nextDraft, updatedAt: publishedAt }, null, 2), 'utf8')
|
||||
state.cachedDraftSiteContent = nextDraft
|
||||
state.publishState.draftUpdatedAt = publishedAt
|
||||
} catch (err) {
|
||||
console.error('[series-scheduler] Failed to update draft after transition:', err)
|
||||
|
||||
@@ -16,7 +16,8 @@ const publicCertRateLimiter = rateLimit({
|
||||
})
|
||||
|
||||
/**
|
||||
* Returns the list of required section IDs for a study (only released sections).
|
||||
* Returns the list of required section IDs for a study. Every lesson is required, so a
|
||||
* certificate is only available once the whole study has been released and completed.
|
||||
*/
|
||||
function getRequiredSectionIds(studySlug) {
|
||||
const content = state.cachedSiteContent
|
||||
@@ -24,7 +25,7 @@ function getRequiredSectionIds(studySlug) {
|
||||
const study = content.studies.find(s => s?.slug === studySlug)
|
||||
if (!study || !Array.isArray(study.sections)) return null
|
||||
return study.sections
|
||||
.filter(s => s?.status !== 'unreleased' && s?.id)
|
||||
.filter(s => s?.id)
|
||||
.map(s => s.id)
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -74,6 +74,17 @@ export function hashEmailVerifyToken(token) {
|
||||
|
||||
// ── Catalog / enrollment helpers ───────────────────────────────────────────
|
||||
|
||||
// A study is 'complete' when an admin marks it so, or when it has lessons and every
|
||||
// one of them has already been released. Planned studies never auto-complete.
|
||||
export function getEffectiveStudyStatus(study) {
|
||||
if (!study || typeof study !== 'object') return 'active'
|
||||
if (study.status === 'planned') return 'planned'
|
||||
if (study.status === 'complete') return 'complete'
|
||||
const sections = Array.isArray(study.sections) ? study.sections : []
|
||||
if (sections.length > 0 && sections.every(isSectionReleased)) return 'complete'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
export function getStudyCatalog() {
|
||||
const fallback = [
|
||||
{ slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' },
|
||||
@@ -91,7 +102,7 @@ export function getStudyCatalog() {
|
||||
out.push({
|
||||
slug,
|
||||
title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug,
|
||||
status: study?.status === 'planned' ? 'planned' : 'active',
|
||||
status: getEffectiveStudyStatus(study),
|
||||
})
|
||||
}
|
||||
if (out.length > 0) return out
|
||||
@@ -743,9 +754,13 @@ export async function scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) {
|
||||
: undefined
|
||||
if (!study) continue
|
||||
|
||||
// Finished studies are self-paced: only mention lessons from the last week so a
|
||||
// late enrollee isn't sent the whole back catalogue in one digest.
|
||||
const isComplete = getEffectiveStudyStatus(study) === 'complete'
|
||||
for (const section of study.sections ?? []) {
|
||||
const releaseTime = getSectionReleaseTime(section)
|
||||
if (!Number.isFinite(releaseTime) || releaseTime > nowMs) continue
|
||||
if (isComplete && nowMs - releaseTime > ONE_WEEK_MS) continue
|
||||
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
|
||||
if (sentForStudy.includes(section.id)) continue
|
||||
|
||||
|
||||
Reference in new issue
Block a user