From f254e3ec190f3aa085e31c1ca08703027b37bc5b Mon Sep 17 00:00:00 2001 From: nmemmert Date: Fri, 18 Sep 2026 11:45:02 -0400 Subject: [PATCH] Add scheduled series auto-launch for series card, Start Here, and study guide; v2.0.36 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 --- package.json | 2 +- server.js | 10 ++- server/routes/admin-content.js | 67 +++++++++++++++ src/AdminPage.tsx | 144 +++++++++++++++++++++++++++++++++ src/content.ts | 31 +++++++ 5 files changed, 252 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1a86fe7..1854aa6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.0.35", + "version": "2.0.36", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index e07e9c0..b395868 100644 --- a/server.js +++ b/server.js @@ -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}`) diff --git a/server/routes/admin-content.js b/server/routes/admin-content.js index c3166fa..bae84c0 100644 --- a/server/routes/admin-content.js +++ b/server/routes/admin-content.js @@ -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' diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index f4c4f11..1465ee7 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -4014,6 +4014,150 @@ async function handleAssetUpload(event: ChangeEvent) { )} + + {/* Schedule Auto-Transition */} + {(() => { + const sched = form.scheduledSeriesTransition + const patch = (p: Partial>) => + setForm(f => ({ ...f, scheduledSeriesTransition: { ...(f.scheduledSeriesTransition ?? { triggersAt: '', newTitle: '', newSeason: 2 }), ...p } })) + return ( +
+
+

Schedule Auto Series Launch

+

+ Set one date & 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. +

+ {sched?.triggersAt && ( +
+

+ Scheduled: Launch “{sched.newTitle}” on{' '} + {new Date(sched.triggersAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })} +

+
+ )} + +
+ + patch({ triggersAt: e.target.value ? new Date(e.target.value).toISOString() : '' })} + /> +
+ +

New Series Card

+
+ + patch({ newTitle: e.target.value })} /> +
+
+ +