From 50b0e83c2bbb60097d41de72995c9f141e59cfd8 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 12 Oct 2026 11:54:00 -0400 Subject: [PATCH] Add Season Check page and dashboard card: per-lane readiness (season, study, lessons, highlights, feed); v2.20.0 Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 4 +- package.json | 2 +- server.js | 2 + server/feed-release.js | 2 +- server/routes/admin-dashboard.js | 10 ++ server/season-check.js | 133 ++++++++++++++++++++++++ src/AdminPage.tsx | 111 +++++++++++++++++++- src/components/AdminDashboardExtras.tsx | 23 ++++ 8 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 server/season-check.js diff --git a/package-lock.json b/package-lock.json index eb3fdf1..ff1ec0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.19.0", + "version": "2.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.19.0", + "version": "2.20.0", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 40a6b10..044e4a7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.19.0", + "version": "2.20.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index b045d27..6616877 100644 --- a/server.js +++ b/server.js @@ -67,6 +67,7 @@ import { register as registerNuggets } from './server/routes/nuggets.js' import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js' import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js' import { register as registerPodcastWorkflow } from './server/routes/podcast-workflow.js' +import { register as registerSeasonCheck } from './server/season-check.js' import { warmFeedSnapshot } from './server/feed-release.js' import { preferIpv4ForSeafile } from './server/seafile-dns.js' import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js' @@ -129,6 +130,7 @@ registerNuggets(app) registerNuggetsHostUpload(app) registerNuggetsWorkflow(app) registerPodcastWorkflow(app) +registerSeasonCheck(app) registerFeed(app) registerQrCodes(app) registerBiblePassage(app) diff --git a/server/feed-release.js b/server/feed-release.js index 78e5598..cc0ef51 100644 --- a/server/feed-release.js +++ b/server/feed-release.js @@ -22,7 +22,7 @@ function refreshSnapshot() { return inflight } -async function getFeedIndex() { +export async function getFeedIndex() { if (!snapshot.at) await refreshSnapshot() else if (Date.now() - snapshot.at > SNAPSHOT_TTL_MS) refreshSnapshot() diff --git a/server/routes/admin-dashboard.js b/server/routes/admin-dashboard.js index 5fb087d..ae5c47d 100644 --- a/server/routes/admin-dashboard.js +++ b/server/routes/admin-dashboard.js @@ -1,6 +1,7 @@ import { Resend } from 'resend' import { readdir } from 'node:fs/promises' import { requireAdminAuth } from '../auth.js' +import { buildSeasonCheck } from '../season-check.js' import { state } from '../state.js' import { getStorageStatus, loadUserProgress } from '../data.js' import { getStudyCatalog } from '../study-helpers.js' @@ -219,7 +220,16 @@ export function register(app) { buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(), ]) const comments = state.studyComments ?? [] + const seasonCheck = await buildSeasonCheck() + .then(c => ({ + total: c.total, + errors: c.errors, + warnings: c.warnings, + lanes: c.lanes.map(l => ({ lane: l.lane, title: l.title, season: l.season, errors: l.errors, warnings: l.warnings })), + })) + .catch(() => null) res.json({ + seasonCheck, trends: buildTrends(), traffic: buildTopTraffic(), reengagement: buildReengagement(), diff --git a/server/season-check.js b/server/season-check.js new file mode 100644 index 0000000..52fd271 --- /dev/null +++ b/server/season-check.js @@ -0,0 +1,133 @@ +import { state } from './state.js' +import { requireAdminAuth } from './auth.js' +import { scriptKey } from './episode-script-keys.js' +import { getFeedIndex } from './feed-release.js' +import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js' + +// One report per lane (A = primary series, B = second series) listing what is set up, what is +// missing, and an episode-by-episode grid. Reads the working draft so problems show up before +// publishing. Read-only: it never changes anything. + +const err = (message, view) => ({ severity: 'error', message, view }) +const warn = (message, view) => ({ severity: 'warn', message, view }) + +function laneConfig(content, lane) { + if (lane === 'A') { + return { + title: String(content.seriesTitle ?? '').trim(), + studySlug: String(content.seriesStudySlug ?? '').trim(), + highlightLane: 'primary', + } + } + const second = content.secondSeries ?? {} + return { + title: String(second.title ?? '').trim(), + studySlug: String(second.studySlug ?? '').trim(), + highlightLane: 'second', + } +} + +export async function buildSeasonCheck() { + const content = state.cachedDraftSiteContent ?? state.cachedSiteContent ?? {} + const [wf, index] = await Promise.all([loadPodcastWorkflow(), getFeedIndex()]) + const seasons = { A: laneSeasonOf('A', wf, content), B: laneSeasonOf('B', wf, content) } + + const studies = Array.isArray(content.studies) ? content.studies : [] + const highlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : [] + const planned = new Set(wf.planned.map(p => scriptKey(p.season, p.episodeNumber))) + + const lanes = [] + for (const lane of ['A', 'B']) { + const cfg = laneConfig(content, lane) + // Lane B only exists once a second series has been set up + if (lane === 'B' && !cfg.title && !cfg.studySlug && !seasons.B) continue + + const season = seasons[lane] + const problems = [] + if (!cfg.title) problems.push(warn(`Lane ${lane} has no series title.`, 'podcast:current-series')) + if (!season) problems.push(err(`Lane ${lane} has no season number. Highlights and lesson audio can't match the feed without it.`, 'podcast:workflow')) + if (season && lane === 'B' && season === seasons.A) problems.push(err(`Lanes A and B both use season ${season}.`, 'podcast:workflow')) + + const study = studies.find(s => s?.lane === lane) ?? studies.find(s => s?.slug && s.slug === cfg.studySlug) + if (!study) problems.push(warn(`No study is linked to Lane ${lane}. Set the study's lane, or link it in Current Series.`, 'colossians-study')) + else if (study.lane !== lane) problems.push(warn(`"${study.title}" is linked to Lane ${lane} in Current Series but the study's own lane isn't set to ${lane}.`, 'colossians-study')) + + const sections = Array.isArray(study?.sections) ? study.sections : [] + const laneHighlights = highlights.filter(h => (h.lane ?? 'primary') === cfg.highlightLane) + + const noNumber = sections.filter(s => !String(s.episodeNumber ?? '').trim()) + if (study && noNumber.length > 0) { + problems.push(warn(`${noNumber.length} lesson${noNumber.length === 1 ? '' : 's'} in "${study.title}" ${noNumber.length === 1 ? 'has' : 'have'} no episode number, so ${noNumber.length === 1 ? 'it' : 'they'} can't pick up audio.`, 'colossians-study')) + } + const noDate = sections.filter(s => String(s.episodeNumber ?? '').trim() && !s.releasedAt) + if (study && noDate.length > 0) { + problems.push(warn(`${noDate.length} lesson${noDate.length === 1 ? '' : 's'} with an episode number ${noDate.length === 1 ? 'has' : 'have'} no release date.`, 'colossians-study')) + } + const noHighlightNumber = laneHighlights.filter(h => !String(h.episodeNumber ?? '').trim()) + if (noHighlightNumber.length > 0) { + problems.push(warn(`${noHighlightNumber.length} highlight${noHighlightNumber.length === 1 ? '' : 's'} in Lane ${lane} ${noHighlightNumber.length === 1 ? 'has' : 'have'} no episode number.`, 'podcast:highlights')) + } + + // Episode numbers to show: every number any part of the season mentions, plus 1..lesson count + const numbers = new Set() + for (let n = 1; n <= sections.length; n++) numbers.add(n) + const addNumber = v => { const n = Number(v); if (Number.isInteger(n) && n > 0) numbers.add(n) } + sections.forEach(s => addNumber(s.episodeNumber)) + laneHighlights.forEach(h => addNumber(h.episodeNumber)) + for (const [key, item] of index) if (season && key.startsWith(`${season}-`)) addNumber(item.episode) + for (const p of wf.planned) if (season && String(p.season) === season) addNumber(p.episodeNumber) + for (const key of Object.keys(state.episodeScripts ?? {})) if (season && key.startsWith(`${season}-`)) addNumber(key.split('-')[1]) + + const episodes = [...numbers].sort((a, b) => a - b).map(n => { + const num = String(n) + const feedItem = season ? index.get(`${season}-${num}`) : undefined + const released = Boolean(feedItem) + const lessons = sections.filter(s => String(s.episodeNumber ?? '').trim() === num) + const hl = laneHighlights.filter(h => String(h.episodeNumber ?? '').trim() === num) + const lessonHasAudio = lessons.some(s => s.audioEmbedUrl?.trim() || (released && feedItem?.audioUrl)) + + if (lessons.length > 1) problems.push(warn(`Episode ${num}: ${lessons.length} lessons share this episode number.`, 'colossians-study')) + if (hl.length > 1) problems.push(warn(`Episode ${num}: ${hl.length} highlights share this episode number.`, 'podcast:highlights')) + if (season && !released && hl.some(h => !h.holdUntilReleased && h.lane !== 'general')) { + problems.push(warn(`Episode ${num}: the highlight will show on the site before the episode is out. Turn on "Publish when the episode is released".`, 'podcast:highlights')) + } + + return { + number: n, + released, + releaseDate: feedItem?.pubDate ? new Date(feedItem.pubDate).toISOString().slice(0, 10) : '', + workflowStarted: season ? planned.has(scriptKey(season, num)) : false, + script: season ? Boolean(state.episodeScripts?.[scriptKey(season, num)]) : false, + lesson: lessons.length > 0, + lessonAudio: lessons.length > 0 ? lessonHasAudio : null, + highlight: hl.length > 0, + highlightHeld: hl.length > 0 ? hl.every(h => h.holdUntilReleased === true) : null, + } + }) + + lanes.push({ + lane, + title: cfg.title, + season, + studyTitle: study?.title ?? '', + problems, + errors: problems.filter(p => p.severity === 'error').length, + warnings: problems.filter(p => p.severity === 'warn').length, + episodes, + }) + } + + const total = lanes.reduce((n, l) => n + l.problems.length, 0) + return { lanes, total, errors: lanes.reduce((n, l) => n + l.errors, 0), warnings: lanes.reduce((n, l) => n + l.warnings, 0) } +} + +export function register(app) { + app.get('/api/admin/season-check', requireAdminAuth, async (_req, res) => { + try { + res.json(await buildSeasonCheck()) + } catch (err) { + console.error('[season-check] failed:', err) + res.status(500).json({ message: 'Could not build the season check.' }) + } + }) +} diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index f15ac3f..d122a27 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -730,7 +730,7 @@ type AdminView = | 'start-here' | 'about:show-copy' | 'about:bio' | 'about:testimonials' | 'contact:profile' | 'contact:copy' - | 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:workflow' | 'podcast:rss' + | 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:workflow' | 'podcast:season-check' | 'podcast:rss' | 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow' | 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources' | 'inbox' @@ -778,6 +778,7 @@ const ADMIN_NAV: NavGroup[] = [ group: 'Content', items: [ { kind: 'parent', id: 'podcast', label: 'Podcast Hub', children: [ + { kind: 'leaf', view: 'podcast:season-check', label: 'Season Check' }, { kind: 'leaf', view: 'podcast:current-series', label: 'Current Series' }, { kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' }, { kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' }, @@ -2122,6 +2123,113 @@ function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string }; lane ) } +// ── Season Check (everything a season needs, per lane) ───────────────────── +interface SeasonCheckProblem { severity: 'error' | 'warn'; message: string; view: string } +interface SeasonCheckEpisode { + number: number + released: boolean + releaseDate: string + workflowStarted: boolean + script: boolean + lesson: boolean + lessonAudio: boolean | null + highlight: boolean + highlightHeld: boolean | null +} +interface SeasonCheckLane { + lane: 'A' | 'B' + title: string + season: string + studyTitle: string + problems: SeasonCheckProblem[] + errors: number + warnings: number + episodes: SeasonCheckEpisode[] +} + +function SeasonCheckAdmin({ onNavigate }: { onNavigate: (view: AdminView) => void }) { + const [report, setReport] = useState<{ lanes: SeasonCheckLane[]; total: number } | null>(null) + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + + const fetchReport = () => + fetch('/api/admin/season-check') + .then(r => (r.ok ? r.json() : Promise.reject(new Error('failed')))) + .then(d => { setReport(d); setError('') }) + .catch(() => setError('Could not run the season check.')) + .finally(() => setLoading(false)) + useEffect(() => { fetchReport() }, []) + + const yes = ✓ + const no = – + const cell = { padding: '4px 8px', textAlign: 'center' as const } + + return ( +
+
+

Podcast — Season Check

+

Everything a season needs, per lane. This reads your saved draft, so it catches gaps before you publish. Nothing here changes anything.

+
+ + {error &&

{error}

} + {report && report.lanes.length === 0 &&

No lanes are set up yet.

} + {report?.lanes.map(l => ( +
+

+ Lane {l.lane}{l.title ? ` — ${l.title}` : ''} +

+

+ {l.season ? `Season ${l.season}` : 'No season number'}{l.studyTitle ? ` · Study: ${l.studyTitle}` : ' · No study linked'} +

+ {l.problems.length === 0 ? ( +

✓ Nothing to fix.

+ ) : ( +
    + {l.problems.map((p, i) => ( +
  • + {p.severity === 'error' ? '●' : '▲'} + {p.message} + +
  • + ))} +
+ )} + {l.episodes.length > 0 && ( +
+ + + + + + + + + + + + + + {l.episodes.map(e => ( + + + + + + + + + + ))} + +
EpWorkflowScriptLessonLesson audioHighlightLive on Spotify
{e.number}{e.released ? 'Released' : e.workflowStarted ? 'In progress' : no}{e.script ? yes : no}{e.lesson ? yes : no}{e.lessonAudio === null ? no : e.lessonAudio ? yes : no}{e.highlight ? (e.highlightHeld ? 'Waits for release' : yes) : no}{e.released ? <>{yes} {e.releaseDate} : no}
+
+ )} +
+ ))} +
+ ) +} + export default function AdminPage({ content, onSave, onLogout }: Props) { const normalizedContent = normalizeSiteContentForAdmin(content) const [form, setForm] = useState(normalizedContent) @@ -6334,6 +6442,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) )} {/* EPISODE SCRIPTS */} + {adminView === 'podcast:season-check' && } {adminView === 'podcast:workflow' && } {adminView === 'podcast:scripts' && (
diff --git a/src/components/AdminDashboardExtras.tsx b/src/components/AdminDashboardExtras.tsx index f65b336..cc7e4bf 100644 --- a/src/components/AdminDashboardExtras.tsx +++ b/src/components/AdminDashboardExtras.tsx @@ -4,6 +4,7 @@ interface Trend { thisWeek: number; lastWeek: number; series: number[] } interface ResendStatus { available: boolean; reason?: string; broadcast?: { id: string; name: string; subject: string; sentAt: string | null; dashboardUrl: string } | null } export interface DashboardData { + seasonCheck: { total: number; errors: number; warnings: number; lanes: { lane: string; title: string; season: string; errors: number; warnings: number }[] } | null trends: { visits: Trend; subscribers: Trend; studySignups: Trend } traffic: { topPages: { path: string; hits: number }[]; topReferrers: { referrer: string; count: number }[] } reengagement: { t1: number; t2: number; t3: number; recentlySent: number; remindersEnabled: number } @@ -90,6 +91,7 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN const attention: { key: string; label: string; tone: 'alert' | 'ok'; action?: () => void; actionLabel?: string }[] = [] if (data) { + if (data.seasonCheck && data.seasonCheck.total > 0) attention.push({ key: 'season-check', label: `Season check: ${data.seasonCheck.total} thing${data.seasonCheck.total === 1 ? '' : 's'} to fix`, tone: 'alert', action: () => onNavigate('podcast:season-check'), actionLabel: 'Open Season Check' }) if (data.comments.pending > 0) attention.push({ key: 'comments', label: `${data.comments.pending} study comment${data.comments.pending === 1 ? '' : 's'} awaiting approval`, tone: 'alert', action: () => onNavigate('study-comments'), actionLabel: 'Review' }) if (data.nuggets.uploadsThisWeek > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.uploadsThisWeek} Nuggets upload${data.nuggets.uploadsThisWeek === 1 ? '' : 's'} in the last 7 days`, tone: 'alert', action: () => onNavigate('nuggets:history'), actionLabel: 'View uploads' }) if (data.latestEpisode.available && data.latestEpisode.stale) attention.push({ key: 'feed', label: `Podcast feed has no new episode in ${data.latestEpisode.daysAgo} days`, tone: 'alert', action: () => onNavigate('podcast:rss'), actionLabel: 'Check feed' }) @@ -137,6 +139,27 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN )} + {data.seasonCheck && data.seasonCheck.lanes.length > 0 && ( + <> +

Season check

+
+ {data.seasonCheck.lanes.map(l => { + const issues = l.errors + l.warnings + return ( +
+
{issues === 0 ? '✓' : issues}
+
Lane {l.lane}{l.title ? ` — ${l.title}` : ''}
+
+ {l.season ? `Season ${l.season}` : 'No season number set'} · {issues === 0 ? 'all set' : `${issues} to fix`} +
+ +
+ ) + })} +
+ + )} +

Trends

onNavigate('analytics')} actionLabel="Analytics" />