Fix scheduled series launch: read schedule from draft, apply to draft+published, check every minute, local-time picker; v2.8.1
Publish Container / build (push) Successful in 16m22s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:12:05 -04:00
1 parent 134a2c403e
commit 0ff0b32b97
4 files changed
+68 -43

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.8.0",
"version": "2.8.1",
"type": "module",
"scripts": {
"dev": "vite",
+2 -2
View File
@@ -228,12 +228,12 @@ Promise.all([
console.error('[study-digest] failed to schedule weekly digest:', err)
})
// Check for a scheduled series transition every hour
// Check for a scheduled series transition every minute
setInterval(() => {
runScheduledSeriesTransition().catch(err => {
console.error('[series-scheduler] error:', err)
})
}, 60 * 60 * 1000)
}, 60 * 1000)
runScheduledSeriesTransition().catch(err => {
console.error('[series-scheduler] error:', err)
})
+56 -38
View File
@@ -53,18 +53,8 @@ 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)
// 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 ?? '',
@@ -73,40 +63,56 @@ export async function runScheduledSeriesTransition() {
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 patch = {}
const copy = (from, to) => {
if (typeof sched[from] === 'string' && sched[from].trim()) patch[to] = sched[from].trim()
}
copy('newStartHereHeading', 'startHereHeading')
copy('newStartHereIntro', 'startHereIntro')
for (const n of [1, 2, 3]) {
copy(`newStartHereStep${n}Title`, `startHereStep${n}Title`)
copy(`newStartHereStep${n}Body`, `startHereStep${n}Body`)
copy(`newStartHereStep${n}Cta`, `startHereStep${n}Cta`)
}
copy('newStudyGuideTitle', 'studyGuideTitle')
copy('newStudyGuideDescription', 'studyGuideDescription')
copy('newStudyGuideDownloadUrl', 'studyGuideDownloadUrl')
copy('newStudyGuideUrl', 'studyGuideUrl')
copy('newStudyGuideAmazonButtonLabel', 'studyGuideAmazonButtonLabel')
copy('newStudyGuideAppleBooksUrl', 'studyGuideAppleBooksUrl')
copy('newStudyGuideAppleBooksLabel', 'studyGuideAppleBooksLabel')
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({
const next = sanitizeSiteContent({
...content,
seriesTitle: sched.newTitle.trim(),
seriesDescription: sched.newDescription?.trim() ?? '',
seriesImageUrl: sched.newImageUrl?.trim() ?? content.seriesImageUrl ?? '',
seriesImageUrl: sched.newImageUrl?.trim() || content.seriesImageUrl || '',
seriesLabel: 'Now Playing',
podcastFeaturedLinks: [],
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), archivedBook],
...startHerePatch,
...studyGuidePatch,
scheduledSeriesTransition: undefined,
...patch,
})
delete next.scheduledSeriesTransition
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.
// 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
console.log('[series-scheduler] Firing scheduled series transition to:', sched.newTitle)
const { next: updatedContent, archivedBook } = applySeriesTransition(published, sched)
const publishedAt = new Date().toISOString()
try {
@@ -117,6 +123,18 @@ export async function runScheduledSeriesTransition() {
console.log('[series-scheduler] Transition complete — archived:', archivedBook.title)
} catch (err) {
console.error('[series-scheduler] Failed to write transition:', err)
return
}
if (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
state.publishState.draftUpdatedAt = publishedAt
} catch (err) {
console.error('[series-scheduler] Failed to update draft after transition:', err)
}
}
}
+9 -2
View File
@@ -1100,6 +1100,13 @@ function buildSiteContentForSave(siteContent: SiteContent): SiteContent {
}
}
function toLocalDatetimeInput(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function QrCodeImage({ url, label }: { url: string; label: string }) {
const canvasRef = React.useRef<HTMLCanvasElement>(null)
const [dataUrl, setDataUrl] = React.useState<string>('')
@@ -4083,7 +4090,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<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.
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 minute. 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' }}>
@@ -4099,7 +4106,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<input
id="sched-triggers-at"
type="datetime-local"
value={sched?.triggersAt ? new Date(sched.triggersAt).toISOString().slice(0, 16) : ''}
value={sched?.triggersAt ? toLocalDatetimeInput(sched.triggersAt) : ''}
onChange={e => patch({ triggersAt: e.target.value ? new Date(e.target.value).toISOString() : '' })}
/>
</div>