diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml index 8f91d64..81479ba 100644 --- a/.gitea/workflows/docker-publish.yml +++ b/.gitea/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Publish Container on: push: - branches: ["main"] + branches: ["main", "beta"] tags: ["v*"] workflow_dispatch: @@ -48,6 +48,7 @@ jobs: images: git.necloud.us/nate/siteforge tags: | type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' }} type=sha type=ref,event=tag diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 8b93b6e..0aa34fb 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Publish Container on: push: - branches: ["main"] + branches: ["main", "beta"] tags: ["v*"] workflow_dispatch: @@ -50,6 +50,7 @@ jobs: images: git.necloud.us/nate/siteforge tags: | type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' }} type=sha type=ref,event=tag diff --git a/package.json b/package.json index 4753247..d489d50 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.8.5", + "version": "2.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/helpers.js b/server/helpers.js index d0d704b..24e1780 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -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), diff --git a/server/routes/admin-content.js b/server/routes/admin-content.js index 74689f7..82b57e8 100644 --- a/server/routes/admin-content.js +++ b/server/routes/admin-content.js @@ -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) diff --git a/server/routes/study-certificate.js b/server/routes/study-certificate.js index ceea801..0f69198 100644 --- a/server/routes/study-certificate.js +++ b/server/routes/study-certificate.js @@ -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) } diff --git a/server/study-helpers.js b/server/study-helpers.js index d88218c..602db76 100644 --- a/server/study-helpers.js +++ b/server/study-helpers.js @@ -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 diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index d2d4253..742227b 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -3,7 +3,7 @@ import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { Link } from 'react-router-dom' -import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings, PodcastHost } from './content' +import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings, PodcastHost, SecondSeries } from './content' import { DEFAULTS } from './content' import { AnalyticsPanel } from './components/AnalyticsPanel' import { AdminCollapsibleCard } from './components/AdminCollapsibleCard' @@ -1613,6 +1613,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [endSeriesNewTitle, setEndSeriesNewTitle] = useState('') const [endSeriesNewImage, setEndSeriesNewImage] = useState('') const [endSeriesNewSeason, setEndSeriesNewSeason] = useState('') + const [bulkStart, setBulkStart] = useState('') + const [bulkInterval, setBulkInterval] = useState('14') + const [bulkOnlyEmpty, setBulkOnlyEmpty] = useState(false) const [questions, setQuestions] = useState([]) const [contactSubmissions, setContactSubmissions] = useState([]) @@ -2784,6 +2787,50 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) })) } + // Stamps release dates on a study's lessons in order: lesson N = start + N × interval days. + // Uses local-time date arithmetic so the time of day survives daylight-saving changes. + function applyBulkSchedule(studyId: string) { + if (!bulkStart) return + const interval = Math.max(1, parseInt(bulkInterval) || 14) + const base = new Date(bulkStart) + if (isNaN(base.getTime())) return + setForm(f => ({ + ...f, + studies: (f.studies ?? []).map(study => study.id !== studyId ? study : { + ...study, + sections: (study.sections ?? []).map((section, index) => { + if (bulkOnlyEmpty && section.releasedAt) return section + const date = new Date(base) + date.setDate(date.getDate() + index * interval) + return { ...section, releasedAt: date.toISOString() } + }), + }), + })) + } + + // Lessons from this study that land on the same calendar day as a lesson in another study. + function findReleaseCollisions(study: StudyProgram) { + const dayKey = (iso?: string) => { + if (!iso) return '' + const d = new Date(iso) + return isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-CA') + } + const otherDays = new Map() + for (const other of form.studies ?? []) { + if (other.id === study.id || other.status === 'planned') continue + for (const section of other.sections ?? []) { + const key = dayKey(section.releasedAt) + if (key) otherDays.set(key, other.title) + } + } + const hits: { date: string; title: string; other: string }[] = [] + for (const section of study.sections ?? []) { + const key = dayKey(section.releasedAt) + if (key && otherDays.has(key)) hits.push({ date: key, title: section.title || section.reference, other: otherDays.get(key) ?? '' }) + } + return hits + } + function updateStudySection(studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | number | string[]) { setForm(f => ({ ...f, @@ -4040,6 +4087,94 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) )} ))} +
+ + +

Adds a “Study Along” button to the series card. When this series ends, its study is marked Complete.

+
+
+ + setForm(f => ({ ...f, seriesSeason: parseInt(e.target.value) || undefined }))} /> +

Must match the season number you set on these episodes in Spotify for Creators. Used for the Finished Books playlist.

+
+ + {(() => { + const second = form.secondSeries + const patchSecond = (p: Partial) => + setForm(f => ({ ...f, secondSeries: { label: 'Now Playing', title: '', description: '', imageUrl: '', listenUrl: '', listenBtnLabel: '', ...(f.secondSeries ?? {}), ...p } })) + return ( +
+
+

Second Series (Lane B)

+

+ Running two books at once? Fill this in and the homepage shows a second Now Playing card. Leave the title blank to show only one series. +

+
+ + patchSecond({ label: e.target.value })} /> +
+
+ + patchSecond({ title: e.target.value })} /> +
+
+ +