Add scheduled series auto-launch for series card, Start Here, and study guide; v2.0.36
Publish Container / docker (push) Successful in 14m20s

One scheduled date/time in the Current Series admin panel archives the active series to
Finished Books and atomically swaps in new content for the series card, Start Here steps,
and Companion Study Guide. Server checks every hour via setInterval; also runs on restart
so the transition fires even if the server was down at the scheduled moment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-18 11:45:02 -04:00
1 parent 06f89d6436
commit f254e3ec19
5 files changed
+252 -2

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.0.35",
"version": "2.0.36",
"type": "module",
"scripts": {
"dev": "vite",
+9 -1
View File
@@ -45,7 +45,7 @@ import { recordVisitor } from './server/routes/analytics.js'
// Route registrars
import { register as registerAdminAuth } from './server/routes/admin-auth.js'
import { register as registerAdminContent } from './server/routes/admin-content.js'
import { register as registerAdminContent, runScheduledSeriesTransition } from './server/routes/admin-content.js'
import { register as registerAdminAssets } from './server/routes/admin-assets.js'
import { register as registerAdminBackup } from './server/routes/admin-backup.js'
import { register as registerStudyAuth } from './server/routes/study-auth.js'
@@ -209,6 +209,14 @@ Promise.all([
}, 60 * 60 * 1000)
void scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail)
// Check for a scheduled series transition every hour
setInterval(() => {
runScheduledSeriesTransition().catch(err => {
console.error('[series-scheduler] error:', err)
})
}, 60 * 60 * 1000)
void runScheduledSeriesTransition()
app.listen(PORT, () => {
logResendEmailAlignmentWarnings()
console.log(`Siteforge listening on http://localhost:${PORT}`)
+67
View File
@@ -53,6 +53,73 @@ function invokeWebhook(url, action) {
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
}
// Applies a scheduled series transition if its triggersAt time has passed.
// Called on a server interval (see server.js). Safe to call if no transition is set.
export async function runScheduledSeriesTransition() {
const content = state.cachedSiteContent
if (!content) return
const sched = content.scheduledSeriesTransition
if (!sched || !sched.triggersAt || !sched.newTitle?.trim()) return
if (new Date(sched.triggersAt) > new Date()) return
console.log('[series-scheduler] Firing scheduled series transition to:', sched.newTitle)
const archivedBook = {
id: Date.now().toString(36),
title: content.seriesTitle ?? '',
description: content.seriesDescription ?? '',
imageUrl: content.seriesImageUrl ?? '',
season: (sched.newSeason ?? 2) - 1 || 1,
}
const startHerePatch = {}
if (sched.newStartHereHeading?.trim()) startHerePatch.startHereHeading = sched.newStartHereHeading.trim()
if (sched.newStartHereIntro?.trim()) startHerePatch.startHereIntro = sched.newStartHereIntro.trim()
if (sched.newStartHereStep1Title?.trim()) startHerePatch.startHereStep1Title = sched.newStartHereStep1Title.trim()
if (sched.newStartHereStep1Body?.trim()) startHerePatch.startHereStep1Body = sched.newStartHereStep1Body.trim()
if (sched.newStartHereStep1Cta?.trim()) startHerePatch.startHereStep1Cta = sched.newStartHereStep1Cta.trim()
if (sched.newStartHereStep2Title?.trim()) startHerePatch.startHereStep2Title = sched.newStartHereStep2Title.trim()
if (sched.newStartHereStep2Body?.trim()) startHerePatch.startHereStep2Body = sched.newStartHereStep2Body.trim()
if (sched.newStartHereStep2Cta?.trim()) startHerePatch.startHereStep2Cta = sched.newStartHereStep2Cta.trim()
if (sched.newStartHereStep3Title?.trim()) startHerePatch.startHereStep3Title = sched.newStartHereStep3Title.trim()
if (sched.newStartHereStep3Body?.trim()) startHerePatch.startHereStep3Body = sched.newStartHereStep3Body.trim()
if (sched.newStartHereStep3Cta?.trim()) startHerePatch.startHereStep3Cta = sched.newStartHereStep3Cta.trim()
const studyGuidePatch = {}
if (sched.newStudyGuideTitle?.trim()) studyGuidePatch.studyGuideTitle = sched.newStudyGuideTitle.trim()
if (sched.newStudyGuideDescription?.trim()) studyGuidePatch.studyGuideDescription = sched.newStudyGuideDescription.trim()
if (sched.newStudyGuideDownloadUrl?.trim()) studyGuidePatch.studyGuideDownloadUrl = sched.newStudyGuideDownloadUrl.trim()
if (sched.newStudyGuideUrl?.trim()) studyGuidePatch.studyGuideUrl = sched.newStudyGuideUrl.trim()
if (sched.newStudyGuideAmazonButtonLabel?.trim()) studyGuidePatch.studyGuideAmazonButtonLabel = sched.newStudyGuideAmazonButtonLabel.trim()
if (sched.newStudyGuideAppleBooksUrl?.trim()) studyGuidePatch.studyGuideAppleBooksUrl = sched.newStudyGuideAppleBooksUrl.trim()
if (sched.newStudyGuideAppleBooksLabel?.trim()) studyGuidePatch.studyGuideAppleBooksLabel = sched.newStudyGuideAppleBooksLabel.trim()
const updatedContent = 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],
...startHerePatch,
...studyGuidePatch,
scheduledSeriesTransition: undefined,
})
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
state.publishState.publishedAt = publishedAt
console.log('[series-scheduler] Transition complete — archived:', archivedBook.title)
} catch (err) {
console.error('[series-scheduler] Failed to write transition:', err)
}
}
export function register(app) {
app.get('/api/admin-content', async (req, res) => {
const source = req.query?.source === 'draft' ? 'draft' : 'published'
+144
View File
@@ -4014,6 +4014,150 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
</div>
)}
</div>
{/* Schedule Auto-Transition */}
{(() => {
const sched = form.scheduledSeriesTransition
const patch = (p: Partial<NonNullable<typeof sched>>) =>
setForm(f => ({ ...f, scheduledSeriesTransition: { ...(f.scheduledSeriesTransition ?? { triggersAt: '', newTitle: '', newSeason: 2 }), ...p } }))
return (
<div style={{ marginTop: '2rem' }}>
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', marginBottom: '1.5rem' }} />
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Schedule Auto Series Launch</h3>
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
Set one date &amp; time to automatically archive the current series and swap in new content for the Series card, Start Here section, and Companion Study Guide. The server checks every hour. Leave optional fields blank to keep the current values unchanged.
</p>
{sched?.triggersAt && (
<div style={{ background: 'rgba(201,168,76,0.08)', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '0.8rem 1rem', marginBottom: '1rem' }}>
<p style={{ margin: 0, fontSize: '0.88rem', color: '#c9a84c' }}>
<strong>Scheduled:</strong> Launch &ldquo;{sched.newTitle}&rdquo; on{' '}
{new Date(sched.triggersAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
</p>
</div>
)}
<div className="admin-field">
<label htmlFor="sched-triggers-at">Launch Date &amp; Time</label>
<input
id="sched-triggers-at"
type="datetime-local"
value={sched?.triggersAt ? new Date(sched.triggersAt).toISOString().slice(0, 16) : ''}
onChange={e => patch({ triggersAt: e.target.value ? new Date(e.target.value).toISOString() : '' })}
/>
</div>
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>New Series Card</p>
<div className="admin-field">
<label htmlFor="sched-new-title">Series Title</label>
<input id="sched-new-title" type="text" placeholder="e.g. Study of Romans" value={sched?.newTitle ?? ''} onChange={e => patch({ newTitle: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-new-desc">Series Description</label>
<textarea id="sched-new-desc" rows={3} placeholder="Brief description of the new series…" value={sched?.newDescription ?? ''} onChange={e => patch({ newDescription: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-new-image">Series Image URL</label>
<input id="sched-new-image" type="text" placeholder="/images/romans-cover.png" value={sched?.newImageUrl ?? ''} onChange={e => patch({ newImageUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-new-season">New Season Number</label>
<input id="sched-new-season" type="number" min="1" placeholder="2" value={sched?.newSeason ?? ''} onChange={e => patch({ newSeason: parseInt(e.target.value) || 2 })} />
</div>
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>Start Here Section <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional)</span></p>
<div className="admin-field">
<label htmlFor="sched-sh-heading">Heading</label>
<input id="sched-sh-heading" type="text" placeholder="New Here? Start Here" value={sched?.newStartHereHeading ?? ''} onChange={e => patch({ newStartHereHeading: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-intro">Intro</label>
<textarea id="sched-sh-intro" rows={2} placeholder="Intro paragraph…" value={sched?.newStartHereIntro ?? ''} onChange={e => patch({ newStartHereIntro: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s1t">Step 1 Title</label>
<input id="sched-sh-s1t" type="text" value={sched?.newStartHereStep1Title ?? ''} onChange={e => patch({ newStartHereStep1Title: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s1b">Step 1 Body</label>
<textarea id="sched-sh-s1b" rows={2} value={sched?.newStartHereStep1Body ?? ''} onChange={e => patch({ newStartHereStep1Body: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s1c">Step 1 Button Text</label>
<input id="sched-sh-s1c" type="text" value={sched?.newStartHereStep1Cta ?? ''} onChange={e => patch({ newStartHereStep1Cta: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s2t">Step 2 Title</label>
<input id="sched-sh-s2t" type="text" value={sched?.newStartHereStep2Title ?? ''} onChange={e => patch({ newStartHereStep2Title: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s2b">Step 2 Body</label>
<textarea id="sched-sh-s2b" rows={2} value={sched?.newStartHereStep2Body ?? ''} onChange={e => patch({ newStartHereStep2Body: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s2c">Step 2 Button Text</label>
<input id="sched-sh-s2c" type="text" value={sched?.newStartHereStep2Cta ?? ''} onChange={e => patch({ newStartHereStep2Cta: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s3t">Step 3 Title</label>
<input id="sched-sh-s3t" type="text" value={sched?.newStartHereStep3Title ?? ''} onChange={e => patch({ newStartHereStep3Title: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s3b">Step 3 Body</label>
<textarea id="sched-sh-s3b" rows={2} value={sched?.newStartHereStep3Body ?? ''} onChange={e => patch({ newStartHereStep3Body: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s3c">Step 3 Button Text</label>
<input id="sched-sh-s3c" type="text" value={sched?.newStartHereStep3Cta ?? ''} onChange={e => patch({ newStartHereStep3Cta: e.target.value })} />
</div>
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>Companion Study Guide <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional)</span></p>
<div className="admin-field">
<label htmlFor="sched-sg-title">Guide Title</label>
<input id="sched-sg-title" type="text" placeholder="Companion Study Guide" value={sched?.newStudyGuideTitle ?? ''} onChange={e => patch({ newStudyGuideTitle: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sg-desc">Guide Description</label>
<textarea id="sched-sg-desc" rows={3} value={sched?.newStudyGuideDescription ?? ''} onChange={e => patch({ newStudyGuideDescription: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sg-download">Download URL</label>
<input id="sched-sg-download" type="url" placeholder="/uploads/guide.pdf" value={sched?.newStudyGuideDownloadUrl ?? ''} onChange={e => patch({ newStudyGuideDownloadUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sg-amazon">Amazon URL</label>
<input id="sched-sg-amazon" type="url" placeholder="https://amazon.com/…" value={sched?.newStudyGuideUrl ?? ''} onChange={e => patch({ newStudyGuideUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sg-amazon-label">Amazon Button Label</label>
<input id="sched-sg-amazon-label" type="text" placeholder="Get it on Amazon" value={sched?.newStudyGuideAmazonButtonLabel ?? ''} onChange={e => patch({ newStudyGuideAmazonButtonLabel: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sg-apple-url">Apple Books URL</label>
<input id="sched-sg-apple-url" type="url" placeholder="https://books.apple.com/…" value={sched?.newStudyGuideAppleBooksUrl ?? ''} onChange={e => patch({ newStudyGuideAppleBooksUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sg-apple-label">Apple Books Button Label</label>
<input id="sched-sg-apple-label" type="text" placeholder="Get it on Apple Books" value={sched?.newStudyGuideAppleBooksLabel ?? ''} onChange={e => patch({ newStudyGuideAppleBooksLabel: e.target.value })} />
</div>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn-primary"
disabled={!sched?.triggersAt || !sched?.newTitle?.trim()}
onClick={() => handleSaveDraft()}
>Save Schedule</button>
{sched?.triggersAt && (
<button
type="button"
className="btn-danger-outline"
onClick={() => setForm(f => ({ ...f, scheduledSeriesTransition: undefined }))}
>Clear Schedule</button>
)}
</div>
</div>
)
})()}
</section>
)}
+31
View File
@@ -320,6 +320,37 @@ export interface SiteContent {
episodesSeoIntro: string
// ── Finished Books ──
finishedBooks: FinishedBook[]
// ── Scheduled Series Transition ──
// All fields fire together at triggersAt. Start Here and Study Guide fields are optional —
// omit them to leave those sections unchanged when the series transitions.
scheduledSeriesTransition?: {
triggersAt: string // ISO datetime — server fires at or after this time (checks hourly)
// New series card
newTitle: string
newDescription?: string
newImageUrl?: string
newSeason: number
// Start Here section (optional)
newStartHereHeading?: string
newStartHereIntro?: string
newStartHereStep1Title?: string
newStartHereStep1Body?: string
newStartHereStep1Cta?: string
newStartHereStep2Title?: string
newStartHereStep2Body?: string
newStartHereStep2Cta?: string
newStartHereStep3Title?: string
newStartHereStep3Body?: string
newStartHereStep3Cta?: string
// Companion Study Guide / Featured Download (optional)
newStudyGuideTitle?: string
newStudyGuideDescription?: string
newStudyGuideDownloadUrl?: string
newStudyGuideUrl?: string
newStudyGuideAmazonButtonLabel?: string
newStudyGuideAppleBooksUrl?: string
newStudyGuideAppleBooksLabel?: string
}
// ── Testimonials ──
testimonials: Testimonial[]
// ── Nuggets (sub-show) ──