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

+107 -32
View File
@@ -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)