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
Publish Container / build (push) Successful in 16m22s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
134a2c403e
commit
0ff0b32b97
4 files changed
+68
-43
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.8.0",
|
"version": "2.8.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -228,12 +228,12 @@ Promise.all([
|
|||||||
console.error('[study-digest] failed to schedule weekly digest:', err)
|
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(() => {
|
setInterval(() => {
|
||||||
runScheduledSeriesTransition().catch(err => {
|
runScheduledSeriesTransition().catch(err => {
|
||||||
console.error('[series-scheduler] error:', err)
|
console.error('[series-scheduler] error:', err)
|
||||||
})
|
})
|
||||||
}, 60 * 60 * 1000)
|
}, 60 * 1000)
|
||||||
runScheduledSeriesTransition().catch(err => {
|
runScheduledSeriesTransition().catch(err => {
|
||||||
console.error('[series-scheduler] error:', err)
|
console.error('[series-scheduler] error:', err)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,18 +53,8 @@ function invokeWebhook(url, action) {
|
|||||||
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
|
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applies a scheduled series transition if its triggersAt time has passed.
|
// Builds the content that results from applying a scheduled series transition.
|
||||||
// Called on a server interval (see server.js). Safe to call if no transition is set.
|
function applySeriesTransition(content, sched) {
|
||||||
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 = {
|
const archivedBook = {
|
||||||
id: Date.now().toString(36),
|
id: Date.now().toString(36),
|
||||||
title: content.seriesTitle ?? '',
|
title: content.seriesTitle ?? '',
|
||||||
@@ -73,40 +63,56 @@ export async function runScheduledSeriesTransition() {
|
|||||||
season: (sched.newSeason ?? 2) - 1 || 1,
|
season: (sched.newSeason ?? 2) - 1 || 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
const startHerePatch = {}
|
const patch = {}
|
||||||
if (sched.newStartHereHeading?.trim()) startHerePatch.startHereHeading = sched.newStartHereHeading.trim()
|
const copy = (from, to) => {
|
||||||
if (sched.newStartHereIntro?.trim()) startHerePatch.startHereIntro = sched.newStartHereIntro.trim()
|
if (typeof sched[from] === 'string' && sched[from].trim()) patch[to] = sched[from].trim()
|
||||||
if (sched.newStartHereStep1Title?.trim()) startHerePatch.startHereStep1Title = sched.newStartHereStep1Title.trim()
|
}
|
||||||
if (sched.newStartHereStep1Body?.trim()) startHerePatch.startHereStep1Body = sched.newStartHereStep1Body.trim()
|
copy('newStartHereHeading', 'startHereHeading')
|
||||||
if (sched.newStartHereStep1Cta?.trim()) startHerePatch.startHereStep1Cta = sched.newStartHereStep1Cta.trim()
|
copy('newStartHereIntro', 'startHereIntro')
|
||||||
if (sched.newStartHereStep2Title?.trim()) startHerePatch.startHereStep2Title = sched.newStartHereStep2Title.trim()
|
for (const n of [1, 2, 3]) {
|
||||||
if (sched.newStartHereStep2Body?.trim()) startHerePatch.startHereStep2Body = sched.newStartHereStep2Body.trim()
|
copy(`newStartHereStep${n}Title`, `startHereStep${n}Title`)
|
||||||
if (sched.newStartHereStep2Cta?.trim()) startHerePatch.startHereStep2Cta = sched.newStartHereStep2Cta.trim()
|
copy(`newStartHereStep${n}Body`, `startHereStep${n}Body`)
|
||||||
if (sched.newStartHereStep3Title?.trim()) startHerePatch.startHereStep3Title = sched.newStartHereStep3Title.trim()
|
copy(`newStartHereStep${n}Cta`, `startHereStep${n}Cta`)
|
||||||
if (sched.newStartHereStep3Body?.trim()) startHerePatch.startHereStep3Body = sched.newStartHereStep3Body.trim()
|
}
|
||||||
if (sched.newStartHereStep3Cta?.trim()) startHerePatch.startHereStep3Cta = sched.newStartHereStep3Cta.trim()
|
copy('newStudyGuideTitle', 'studyGuideTitle')
|
||||||
|
copy('newStudyGuideDescription', 'studyGuideDescription')
|
||||||
|
copy('newStudyGuideDownloadUrl', 'studyGuideDownloadUrl')
|
||||||
|
copy('newStudyGuideUrl', 'studyGuideUrl')
|
||||||
|
copy('newStudyGuideAmazonButtonLabel', 'studyGuideAmazonButtonLabel')
|
||||||
|
copy('newStudyGuideAppleBooksUrl', 'studyGuideAppleBooksUrl')
|
||||||
|
copy('newStudyGuideAppleBooksLabel', 'studyGuideAppleBooksLabel')
|
||||||
|
|
||||||
const studyGuidePatch = {}
|
const next = sanitizeSiteContent({
|
||||||
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,
|
...content,
|
||||||
seriesTitle: sched.newTitle.trim(),
|
seriesTitle: sched.newTitle.trim(),
|
||||||
seriesDescription: sched.newDescription?.trim() ?? '',
|
seriesDescription: sched.newDescription?.trim() ?? '',
|
||||||
seriesImageUrl: sched.newImageUrl?.trim() ?? content.seriesImageUrl ?? '',
|
seriesImageUrl: sched.newImageUrl?.trim() || content.seriesImageUrl || '',
|
||||||
seriesLabel: 'Now Playing',
|
seriesLabel: 'Now Playing',
|
||||||
podcastFeaturedLinks: [],
|
podcastFeaturedLinks: [],
|
||||||
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), archivedBook],
|
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), archivedBook],
|
||||||
...startHerePatch,
|
...patch,
|
||||||
...studyGuidePatch,
|
|
||||||
scheduledSeriesTransition: undefined,
|
|
||||||
})
|
})
|
||||||
|
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()
|
const publishedAt = new Date().toISOString()
|
||||||
try {
|
try {
|
||||||
@@ -117,6 +123,18 @@ export async function runScheduledSeriesTransition() {
|
|||||||
console.log('[series-scheduler] Transition complete — archived:', archivedBook.title)
|
console.log('[series-scheduler] Transition complete — archived:', archivedBook.title)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[series-scheduler] Failed to write transition:', 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
@@ -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 }) {
|
function QrCodeImage({ url, label }: { url: string; label: string }) {
|
||||||
const canvasRef = React.useRef<HTMLCanvasElement>(null)
|
const canvasRef = React.useRef<HTMLCanvasElement>(null)
|
||||||
const [dataUrl, setDataUrl] = React.useState<string>('')
|
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' }} />
|
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', marginBottom: '1.5rem' }} />
|
||||||
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Schedule Auto Series Launch</h3>
|
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Schedule Auto Series Launch</h3>
|
||||||
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
|
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
|
||||||
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.
|
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 minute. Leave optional fields blank to keep the current values unchanged.
|
||||||
</p>
|
</p>
|
||||||
{sched?.triggersAt && (
|
{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' }}>
|
<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
|
<input
|
||||||
id="sched-triggers-at"
|
id="sched-triggers-at"
|
||||||
type="datetime-local"
|
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() : '' })}
|
onChange={e => patch({ triggersAt: e.target.value ? new Date(e.target.value).toISOString() : '' })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user