Add Season Check page and dashboard card: per-lane readiness (season, study, lessons, highlights, feed); v2.20.0
Publish Container / build (push) Has been cancelled
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
77bce2ae0c
commit
50b0e83c2b
8 files changed
+282
-5
No files matched your search
@@ -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()
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.' })
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user