diff --git a/package.json b/package.json index 0a89ed2..53cb37b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.8.0", + "version": "2.8.1", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index 879ebff..fd7667f 100644 --- a/server.js +++ b/server.js @@ -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) }) diff --git a/server/routes/admin-content.js b/server/routes/admin-content.js index bae84c0..74689f7 100644 --- a/server/routes/admin-content.js +++ b/server/routes/admin-content.js @@ -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) + } } } diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index f897f81..2032ad6 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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(null) const [dataUrl, setDataUrl] = React.useState('') @@ -4083,7 +4090,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())

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. + 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.

{sched?.triggersAt && (
@@ -4099,7 +4106,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) patch({ triggersAt: e.target.value ? new Date(e.target.value).toISOString() : '' })} />