Compare commits

...
4 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 e6bef529e6 Dashboard: Nuggets attention line counts uploads still open in the workflow and clears when finished; v2.21.0
Publish Container / build (push) Successful in 16m6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:02:30 -04:00
nmemmertandClaude Sonnet 5.5 35d3c9ca33 Season Check: series card, study guide, next-season handover and unseasoned-script checks; v2.20.1
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:56:42 -04:00
nmemmertandClaude Sonnet 5.5 50b0e83c2b 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
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:54:00 -04:00
nmemmertandClaude Sonnet 5.5 77bce2ae0c Hold episode highlights until the episode is in the Spotify feed; auto-fill study lesson audio from the feed by season + episode; v2.19.0
Publish Container / build (push) Successful in 16m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:47:22 -04:00
15 changed files with 480 additions and 23 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "siteforge", "name": "siteforge",
"version": "2.18.0", "version": "2.21.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "siteforge", "name": "siteforge",
"version": "2.18.0", "version": "2.21.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1135.0", "@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.18.0", "version": "2.21.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+4
View File
@@ -67,6 +67,8 @@ import { register as registerNuggets } from './server/routes/nuggets.js'
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.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 registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js'
import { register as registerPodcastWorkflow } from './server/routes/podcast-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 { preferIpv4ForSeafile } from './server/seafile-dns.js'
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js' import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
import { register as registerFeed } from './server/routes/feed.js' import { register as registerFeed } from './server/routes/feed.js'
@@ -128,6 +130,7 @@ registerNuggets(app)
registerNuggetsHostUpload(app) registerNuggetsHostUpload(app)
registerNuggetsWorkflow(app) registerNuggetsWorkflow(app)
registerPodcastWorkflow(app) registerPodcastWorkflow(app)
registerSeasonCheck(app)
registerFeed(app) registerFeed(app)
registerQrCodes(app) registerQrCodes(app)
registerBiblePassage(app) registerBiblePassage(app)
@@ -208,6 +211,7 @@ Promise.all([
}, BACKUP_INTERVAL_MS) }, BACKUP_INTERVAL_MS)
startNuggetsSeafileWatcher() startNuggetsSeafileWatcher()
warmFeedSnapshot()
// Purge expired study sessions every hour // Purge expired study sessions every hour
setInterval(() => { setInterval(() => {
+84
View File
@@ -0,0 +1,84 @@
import { fetchAllEpisodes } from './routes/episodes.js'
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
// The Spotify (Anchor) RSS feed only lists episodes that are live, so a season + episode number
// showing up there means the episode has been published. That drives two things on the public site:
// - highlights marked "hold until released" stay hidden until their episode is in the feed
// - study lessons with no audio of their own pick it up from the matching feed episode
const SNAPSHOT_TTL_MS = 5 * 60 * 1000
let snapshot = { items: [], at: 0 }
let inflight = null
function refreshSnapshot() {
if (!inflight) {
inflight = fetchAllEpisodes()
.then(items => { snapshot = { items, at: Date.now() } })
// Keep serving the last good copy; retry on the next TTL window
.catch(() => { snapshot = { items: snapshot.items, at: Date.now() } })
.finally(() => { inflight = null })
}
return inflight
}
export async function getFeedIndex() {
if (!snapshot.at) await refreshSnapshot()
else if (Date.now() - snapshot.at > SNAPSHOT_TTL_MS) refreshSnapshot()
const index = new Map()
for (const item of snapshot.items) {
if (item.season && /^\d+$/.test(item.episode)) index.set(`${item.season}-${item.episode}`, item)
}
return index
}
export function warmFeedSnapshot() {
return refreshSnapshot()
}
// Returns a copy of the published site content with the feed applied. Run it before
// filterSiteContentByReleaseDate so audio is never added to a lesson that is still locked.
export async function applyFeedRelease(content) {
if (!content || typeof content !== 'object' || Array.isArray(content)) return content
const links = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
const studies = Array.isArray(content.studies) ? content.studies : []
if (!links.some(l => l?.holdUntilReleased) && studies.length === 0) return content
const [index, wf] = await Promise.all([getFeedIndex(), loadPodcastWorkflow()])
const seasonA = laneSeasonOf('A', wf, content)
const seasonB = laneSeasonOf('B', wf, content)
const nextLinks = links.flatMap(link => {
// General highlights aren't tied to a series, so there's no season to match against
if (!link?.holdUntilReleased || link.lane === 'general') return [link]
const season = link.lane === 'second' ? seasonB : seasonA
const item = season && link.episodeNumber ? index.get(`${season}-${link.episodeNumber}`) : null
if (!item) return []
return [{ ...link, url: link.url || item.link || '' }]
})
const studyLane = study => {
if (study?.lane === 'A' || study?.lane === 'B') return study.lane
if (study?.slug && study.slug === content.seriesStudySlug) return 'A'
if (study?.slug && study.slug === content.secondSeries?.studySlug) return 'B'
return ''
}
const nextStudies = studies.map(study => {
const lane = studyLane(study)
const season = lane === 'A' ? seasonA : lane === 'B' ? seasonB : ''
if (!season || !Array.isArray(study.sections)) return study
return {
...study,
sections: study.sections.map(section => {
if (!section || section.audioEmbedUrl?.trim() || !section.episodeNumber) return section
const item = index.get(`${season}-${String(section.episodeNumber).trim()}`)
return item?.audioUrl ? { ...section, audioEmbedUrl: item.audioUrl } : section
}),
}
})
return { ...content, podcastFeaturedLinks: nextLinks, studies: nextStudies }
}
+1
View File
@@ -142,6 +142,7 @@ export function sanitizeFeaturedLinks(value) {
return { return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
lane: item.lane === 'second' || item.lane === 'general' ? item.lane : 'primary', lane: item.lane === 'second' || item.lane === 'general' ? item.lane : 'primary',
holdUntilReleased: item.holdUntilReleased === true,
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '', title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '', episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '', summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
+3 -2
View File
@@ -1,3 +1,4 @@
import { applyFeedRelease } from '../feed-release.js'
import { mkdir, readFile } from 'node:fs/promises' import { mkdir, readFile } from 'node:fs/promises'
import { writeFile } from '../atomic-write.js' import { writeFile } from '../atomic-write.js'
import { assertPublicHttpsUrl } from '../url-safety.js' import { assertPublicHttpsUrl } from '../url-safety.js'
@@ -249,7 +250,7 @@ export function register(app) {
try { try {
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE) const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
if (source === 'published') { if (source === 'published') {
const safeSiteContent = filterSiteContentByReleaseDate(parsed.siteContent) const safeSiteContent = filterSiteContentByReleaseDate(await applyFeedRelease(parsed.siteContent))
res.json({ ...parsed, siteContent: safeSiteContent }) res.json({ ...parsed, siteContent: safeSiteContent })
return return
} }
@@ -279,7 +280,7 @@ export function register(app) {
app.get('/api/site-config', async (_req, res) => { app.get('/api/site-config', async (_req, res) => {
try { try {
const parsed = await loadSiteContentFile(DATA_FILE) const parsed = await loadSiteContentFile(DATA_FILE)
const siteContent = parsed.siteContent ?? {} const siteContent = await applyFeedRelease(parsed.siteContent ?? {})
res.json({ res.json({
seo: siteContent.seo ?? DEFAULT_SEO, seo: siteContent.seo ?? DEFAULT_SEO,
legal: siteContent.legal ?? DEFAULT_LEGAL, legal: siteContent.legal ?? DEFAULT_LEGAL,
+14 -1
View File
@@ -1,6 +1,8 @@
import { Resend } from 'resend' import { Resend } from 'resend'
import { readdir } from 'node:fs/promises' import { readdir } from 'node:fs/promises'
import { requireAdminAuth } from '../auth.js' import { requireAdminAuth } from '../auth.js'
import { buildSeasonCheck } from '../season-check.js'
import { countPendingUploads } from './nuggets-workflow.js'
import { state } from '../state.js' import { state } from '../state.js'
import { getStorageStatus, loadUserProgress } from '../data.js' import { getStorageStatus, loadUserProgress } from '../data.js'
import { getStudyCatalog } from '../study-helpers.js' import { getStudyCatalog } from '../study-helpers.js'
@@ -151,6 +153,7 @@ async function buildNuggets() {
const weekAgo = Date.now() - 7 * DAY_MS const weekAgo = Date.now() - 7 * DAY_MS
return { return {
uploadsThisWeek: entries.filter(e => new Date(e.uploadedAt).getTime() >= weekAgo).length, uploadsThisWeek: entries.filter(e => new Date(e.uploadedAt).getTime() >= weekAgo).length,
pending: await countPendingUploads(),
latest: entries.slice(0, 3).map(e => ({ hostName: e.hostName, episodeName: e.episodeName, uploadedAt: e.uploadedAt })), latest: entries.slice(0, 3).map(e => ({ hostName: e.hostName, episodeName: e.episodeName, uploadedAt: e.uploadedAt })),
entries: entries.slice(0, 10), entries: entries.slice(0, 10),
} }
@@ -219,13 +222,23 @@ export function register(app) {
buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(), buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(),
]) ])
const comments = state.studyComments ?? [] const comments = state.studyComments ?? []
const seasonCheck = await buildSeasonCheck()
.then(c => ({
total: c.total,
errors: c.errors,
warnings: c.warnings,
general: c.general.length,
lanes: c.lanes.map(l => ({ lane: l.lane, title: l.title, season: l.season, errors: l.errors, warnings: l.warnings })),
}))
.catch(() => null)
res.json({ res.json({
seasonCheck,
trends: buildTrends(), trends: buildTrends(),
traffic: buildTopTraffic(), traffic: buildTopTraffic(),
reengagement: buildReengagement(), reengagement: buildReengagement(),
studyProgress, studyProgress,
latestEpisode, latestEpisode,
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, latest: nuggets.latest }, nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, pending: nuggets.pending, latest: nuggets.latest },
health, health,
lastBroadcast, lastBroadcast,
comments: { comments: {
+1 -1
View File
@@ -5,7 +5,7 @@ const RSS_FEED_URL = 'https://anchor.fm/s/11068d290/podcast/rss'
let episodesCache = null let episodesCache = null
let episodesCacheAt = 0 let episodesCacheAt = 0
let rawXmlCache = null let rawXmlCache = null
const EPISODES_CACHE_TTL = 30 * 60 * 1000 const EPISODES_CACHE_TTL = 10 * 60 * 1000
export async function fetchAllEpisodes() { export async function fetchAllEpisodes() {
const now = Date.now() const now = Date.now()
+11 -2
View File
@@ -26,7 +26,7 @@ const AUDIO_TYPES = {
export const WORKFLOW_STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify'] export const WORKFLOW_STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify']
async function loadWorkflow() { export async function loadWorkflow() {
try { try {
return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8')) return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
} catch { } catch {
@@ -34,7 +34,7 @@ async function loadWorkflow() {
} }
} }
function entryId(entry) { export function entryId(entry) {
return createHash('sha1').update(`${entry.uploadedAt}|${entry.filename}`).digest('hex').slice(0, 16) return createHash('sha1').update(`${entry.uploadedAt}|${entry.filename}`).digest('hex').slice(0, 16)
} }
@@ -51,6 +51,15 @@ function seafileConfigured() {
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID) return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
} }
// Portal uploads whose workflow still has steps left to do (any age, so an old one keeps showing).
export async function countPendingUploads() {
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
return history.entries.filter(entry => {
const steps = wf.items[entryId(entry)]?.steps ?? {}
return !WORKFLOW_STEPS.every(step => steps[step])
}).length
}
export function register(app) { export function register(app) {
app.get('/api/admin/nuggets-workflow', requireAdminAuth, async (_req, res) => { app.get('/api/admin/nuggets-workflow', requireAdminAuth, async (_req, res) => {
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()]) const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
+8 -9
View File
@@ -11,7 +11,7 @@ const WORKFLOW_FILE = path.join(DATA_DIR, 'podcast-workflow.json')
const STEPS = ['readScript', 'record', 'edit', 'upload'] const STEPS = ['readScript', 'record', 'edit', 'upload']
async function load() { export async function loadPodcastWorkflow() {
try { try {
const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8')) const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {}, laneOverrides: data.laneOverrides ?? {}, laneSeasons: data.laneSeasons ?? {} } return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {}, laneOverrides: data.laneOverrides ?? {}, laneSeasons: data.laneSeasons ?? {} }
@@ -30,10 +30,9 @@ function emptyState() {
} }
// A lane's season comes from the workflow page's own setting, falling back to Current Series. // A lane's season comes from the workflow page's own setting, falling back to Current Series.
function laneSeasonOf(lane, wf) { export function laneSeasonOf(lane, wf, content = state.cachedSiteContent ?? {}) {
const saved = wf.laneSeasons?.[lane] const saved = wf.laneSeasons?.[lane]
if (/^\d+$/.test(String(saved ?? ''))) return String(saved) if (/^\d+$/.test(String(saved ?? ''))) return String(saved)
const content = state.cachedSiteContent ?? {}
const fallback = lane === 'A' ? content.seriesSeason : content.secondSeries?.season const fallback = lane === 'A' ? content.seriesSeason : content.secondSeries?.season
return fallback ? String(fallback) : '' return fallback ? String(fallback) : ''
} }
@@ -55,7 +54,7 @@ function toIsoDate(pubDate) {
export function register(app) { export function register(app) {
app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => { app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => {
const wf = await load() const wf = await loadPodcastWorkflow()
let feed = [] let feed = []
let feedOk = true let feedOk = true
try { try {
@@ -119,7 +118,7 @@ export function register(app) {
res.status(400).json({ message: 'Season and episode number must be whole numbers.' }) res.status(400).json({ message: 'Season and episode number must be whole numbers.' })
return return
} }
const wf = await load() const wf = await loadPodcastWorkflow()
const key = scriptKey(season, episodeNumber) const key = scriptKey(season, episodeNumber)
if (wf.planned.some(p => plannedKey(p) === key)) { if (wf.planned.some(p => plannedKey(p) === key)) {
res.status(409).json({ message: `Season ${season} episode ${episodeNumber} has already been started.` }) res.status(409).json({ message: `Season ${season} episode ${episodeNumber} has already been started.` })
@@ -131,7 +130,7 @@ export function register(app) {
}) })
app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => { app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => {
const wf = await load() const wf = await loadPodcastWorkflow()
wf.planned = wf.planned.filter(p => plannedKey(p) !== req.params.key) wf.planned = wf.planned.filter(p => plannedKey(p) !== req.params.key)
delete wf.items[req.params.key] delete wf.items[req.params.key]
delete wf.laneOverrides[req.params.key] delete wf.laneOverrides[req.params.key]
@@ -141,7 +140,7 @@ export function register(app) {
// Set the season number each lane looks for ('' goes back to the Current Series value). // Set the season number each lane looks for ('' goes back to the Current Series value).
app.put('/api/admin/podcast-workflow-lane-seasons', requireAdminAuth, async (req, res) => { app.put('/api/admin/podcast-workflow-lane-seasons', requireAdminAuth, async (req, res) => {
const wf = await load() const wf = await loadPodcastWorkflow()
for (const lane of ['A', 'B']) { for (const lane of ['A', 'B']) {
const v = req.body?.[lane] const v = req.body?.[lane]
if (v === undefined) continue if (v === undefined) continue
@@ -161,7 +160,7 @@ export function register(app) {
res.status(400).json({ message: 'Lane must be A, B, other or auto.' }) res.status(400).json({ message: 'Lane must be A, B, other or auto.' })
return return
} }
const wf = await load() const wf = await loadPodcastWorkflow()
if (lane === 'auto') delete wf.laneOverrides[req.params.key] if (lane === 'auto') delete wf.laneOverrides[req.params.key]
else wf.laneOverrides[req.params.key] = lane else wf.laneOverrides[req.params.key] = lane
await save(wf) await save(wf)
@@ -170,7 +169,7 @@ export function register(app) {
app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => { app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => {
const { key } = req.params const { key } = req.params
const wf = await load() const wf = await loadPodcastWorkflow()
const prev = { ...emptyState(), ...(wf.items[key] ?? {}) } const prev = { ...emptyState(), ...(wf.items[key] ?? {}) }
const next = { ...prev, steps: { ...prev.steps } } const next = { ...prev, steps: { ...prev.steps } }
const body = req.body ?? {} const body = req.body ?? {}
+3 -1
View File
@@ -1,4 +1,5 @@
import express from 'express' import express from 'express'
import { applyFeedRelease } from '../feed-release.js'
import path from 'node:path' import path from 'node:path'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js' import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js'
@@ -59,6 +60,7 @@ export function register(app) {
content = parsed.siteContent content = parsed.siteContent
} catch { content = {} } } catch { content = {} }
} }
content = await applyFeedRelease(content)
const seo = content?.seo ?? DEFAULT_SEO const seo = content?.seo ?? DEFAULT_SEO
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
@@ -196,7 +198,7 @@ export function register(app) {
// Scripture index — every passage covered across released study lessons and podcast episodes // Scripture index — every passage covered across released study lessons and podcast episodes
app.get('/api/public/scripture-index', async (_req, res) => { app.get('/api/public/scripture-index', async (_req, res) => {
const content = state.cachedSiteContent const content = await applyFeedRelease(state.cachedSiteContent)
const allStudies = Array.isArray(content?.studies) ? content.studies : [] const allStudies = Array.isArray(content?.studies) ? content.studies : []
const allEpisodes = [ const allEpisodes = [
...(Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []), ...(Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []),
+174
View File
@@ -0,0 +1,174 @@
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',
description: String(content.seriesDescription ?? '').trim(),
imageUrl: String(content.seriesImageUrl ?? '').trim(),
listenUrl: String(content.seriesListenUrl ?? '').trim(),
hasGuide: Boolean(String(content.studyGuideTitle ?? '').trim() && (content.studyGuideDownloadUrl || content.studyGuideUrl || content.studyGuideAppleBooksUrl)),
transition: content.scheduledSeriesTransition,
}
}
const second = content.secondSeries ?? {}
return {
title: String(second.title ?? '').trim(),
studySlug: String(second.studySlug ?? '').trim(),
highlightLane: 'second',
description: String(second.description ?? '').trim(),
imageUrl: String(second.imageUrl ?? '').trim(),
listenUrl: String(second.listenUrl ?? '').trim(),
hasGuide: Boolean(String(second.guide?.title ?? '').trim() && (second.guide?.downloadUrl || second.guide?.amazonUrl || second.guide?.appleBooksUrl)),
transition: content.scheduledSecondSeriesTransition,
}
}
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'))
}
// Series card
if (!cfg.imageUrl) problems.push(warn(`Lane ${lane} has no cover image.`, 'podcast:current-series'))
if (!cfg.description) problems.push(warn(`Lane ${lane} has no series description.`, 'podcast:current-series'))
if (!cfg.listenUrl) problems.push(warn(`Lane ${lane} has no listen link.`, 'podcast:current-series'))
if (!cfg.hasGuide) problems.push(warn(`Lane ${lane} has no companion study guide (title plus a download or purchase link).`, 'podcast:current-series'))
// Handover to the next season
const t = cfg.transition
if (t) {
if (!String(t.newTitle ?? '').trim() || !t.newSeason) {
problems.push(warn(`The next-season handover for Lane ${lane} is scheduled but is missing its title or season number.`, 'podcast:current-series'))
} else if (season && String(t.newSeason) === season) {
problems.push(warn(`The next-season handover for Lane ${lane} uses the same season number (${season}) as the current one.`, 'podcast:current-series'))
}
} else if (study && sections.length > 0 && sections.every(x => x.releasedAt && new Date(x.releasedAt) <= new Date())) {
problems.push(warn(`Every lesson in "${study.title}" is released, but no next season is scheduled for Lane ${lane}.`, 'podcast:current-series'))
}
// 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,
})
}
// Scripts are filed by season + episode, so one with no season can't be matched to an episode
const general = []
const unseasoned = Object.entries(state.episodeScripts ?? {}).filter(([, entry]) => !String(entry?.season ?? '').trim())
if (unseasoned.length > 0) {
general.push(warn(`${unseasoned.length} episode script${unseasoned.length === 1 ? ' has' : 's have'} no season number, so ${unseasoned.length === 1 ? 'it' : 'they'} can't be matched to an episode. Use the season backfill in Episode Scripts.`, 'podcast:scripts'))
}
const all = [...lanes.flatMap(l => l.problems), ...general]
return {
lanes,
general,
total: all.length,
errors: all.filter(p => p.severity === 'error').length,
warnings: all.filter(p => p.severity === 'warn').length,
}
}
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.' })
}
})
}
+143 -2
View File
@@ -730,7 +730,7 @@ type AdminView =
| 'start-here' | 'start-here'
| 'about:show-copy' | 'about:bio' | 'about:testimonials' | 'about:show-copy' | 'about:bio' | 'about:testimonials'
| 'contact:profile' | 'contact:copy' | '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' | 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources' | 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'inbox' | 'inbox'
@@ -778,6 +778,7 @@ const ADMIN_NAV: NavGroup[] = [
group: 'Content', group: 'Content',
items: [ items: [
{ kind: 'parent', id: 'podcast', label: 'Podcast Hub', children: [ { 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:current-series', label: 'Current Series' },
{ kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' }, { kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' },
{ kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' }, { kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' },
@@ -2122,6 +2123,127 @@ 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[]; general: SeasonCheckProblem[]; 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 = <span style={{ color: '#4a9c5d' }} aria-label="yes">✓</span>
const no = <span style={{ opacity: 0.35 }} aria-label="no">–</span>
const cell = { padding: '4px 8px', textAlign: 'center' as const }
return (
<section className="admin-panel-section" aria-label="Season check">
<div className="admin-panel-head">
<h2>Podcast — Season Check</h2>
<p>Everything a season needs, per lane. This reads your saved draft, so it catches gaps before you publish. Nothing here changes anything.</p>
</div>
<button type="button" className="btn-admin-ghost" onClick={() => { setLoading(true); fetchReport() }} disabled={loading}>{loading ? 'Checking…' : 'Check again'}</button>
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
{report && report.lanes.length === 0 && <p className="admin-stats-note">No lanes are set up yet.</p>}
{report && report.general.length > 0 && (
<div style={{ marginTop: 20 }}>
<h3 style={{ marginBottom: 4 }}>All lanes</h3>
<ul style={{ listStyle: 'none', padding: 0, margin: '8px 0 12px' }}>
{report.general.map((p, i) => (
<li key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '5px 0' }}>
<span style={{ color: p.severity === 'error' ? '#c0392b' : '#b8860b' }}>{p.severity === 'error' ? '●' : '▲'}</span>
<span style={{ flex: 1 }}>{p.message}</span>
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate(p.view as AdminView)}>Fix →</button>
</li>
))}
</ul>
</div>
)}
{report?.lanes.map(l => (
<div key={l.lane} style={{ marginTop: 20 }}>
<h3 style={{ marginBottom: 4 }}>
Lane {l.lane}{l.title ? ` — ${l.title}` : ''}
</h3>
<p className="admin-stats-note" style={{ marginTop: 0 }}>
{l.season ? `Season ${l.season}` : 'No season number'}{l.studyTitle ? ` · Study: ${l.studyTitle}` : ' · No study linked'}
</p>
{l.problems.length === 0 ? (
<p style={{ color: '#4a9c5d' }}>✓ Nothing to fix.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: '8px 0 12px' }}>
{l.problems.map((p, i) => (
<li key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '5px 0' }}>
<span style={{ color: p.severity === 'error' ? '#c0392b' : '#b8860b' }}>{p.severity === 'error' ? '●' : '▲'}</span>
<span style={{ flex: 1 }}>{p.message}</span>
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate(p.view as AdminView)}>Fix →</button>
</li>
))}
</ul>
)}
{l.episodes.length > 0 && (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
<th style={{ ...cell, textAlign: 'left' }}>Ep</th>
<th style={cell}>Workflow</th>
<th style={cell}>Script</th>
<th style={cell}>Lesson</th>
<th style={cell}>Lesson audio</th>
<th style={cell}>Highlight</th>
<th style={cell}>Live on Spotify</th>
</tr>
</thead>
<tbody>
{l.episodes.map(e => (
<tr key={e.number} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
<td style={{ ...cell, textAlign: 'left' }}><strong>{e.number}</strong></td>
<td style={cell}>{e.released ? 'Released' : e.workflowStarted ? 'In progress' : no}</td>
<td style={cell}>{e.script ? yes : no}</td>
<td style={cell}>{e.lesson ? yes : no}</td>
<td style={cell}>{e.lessonAudio === null ? no : e.lessonAudio ? yes : no}</td>
<td style={cell}>{e.highlight ? (e.highlightHeld ? 'Waits for release' : yes) : no}</td>
<td style={cell}>{e.released ? <>{yes} {e.releaseDate}</> : no}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</section>
)
}
export default function AdminPage({ content, onSave, onLogout }: Props) { export default function AdminPage({ content, onSave, onLogout }: Props) {
const normalizedContent = normalizeSiteContentForAdmin(content) const normalizedContent = normalizeSiteContentForAdmin(content)
const [form, setForm] = useState<SiteContent>(normalizedContent) const [form, setForm] = useState<SiteContent>(normalizedContent)
@@ -2762,6 +2884,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
{ {
id: Date.now().toString(36), id: Date.now().toString(36),
lane: 'primary' as const, lane: 'primary' as const,
holdUntilReleased: true,
title: '', title: '',
episodeNumber: '', episodeNumber: '',
summary: '', summary: '',
@@ -2778,7 +2901,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
})) }))
} }
function updatePodcastLink(id: string, field: keyof PodcastFeaturedLink, value: string) { function updatePodcastLink(id: string, field: keyof PodcastFeaturedLink, value: string | boolean) {
setForm(f => ({ setForm(f => ({
...f, ...f,
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link => link.id === id ? { ...link, [field]: value } : link), podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link => link.id === id ? { ...link, [field]: value } : link),
@@ -5151,6 +5274,22 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
</select> </select>
<p className="admin-field-help">Groups this highlight under its series on the Episodes page. When a series ends, its highlights move onto that book&apos;s Finished Books page. General highlights stay.</p> <p className="admin-field-help">Groups this highlight under its series on the Episodes page. When a series ends, its highlights move onto that book&apos;s Finished Books page. General highlights stay.</p>
</div> </div>
{activeHighlight.lane !== 'general' && (
<div className="admin-field">
<label htmlFor={`podcast-hold-${activeHighlight.id}`}>
<input
id={`podcast-hold-${activeHighlight.id}`}
type="checkbox"
checked={activeHighlight.holdUntilReleased === true}
onChange={e => updatePodcastLink(activeHighlight.id, 'holdUntilReleased', e.target.checked)}
/>{' '}
Publish when the episode is released on Spotify
</label>
<p className="admin-field-help">
Hidden on the site until this lane&apos;s season number and the episode number below appear in your Spotify feed (checked every few minutes). Needs an episode number. Set each lane&apos;s season number on the Episode Workflow page.
</p>
</div>
)}
<div className="admin-field"> <div className="admin-field">
<label htmlFor={`podcast-ep-${activeHighlight.id}`}>Episode Number</label> <label htmlFor={`podcast-ep-${activeHighlight.id}`}>Episode Number</label>
<input id={`podcast-ep-${activeHighlight.id}`} type="text" placeholder="e.g. 42" value={activeHighlight.episodeNumber ?? ''} onChange={e => updatePodcastLink(activeHighlight.id, 'episodeNumber', e.target.value)} /> <input id={`podcast-ep-${activeHighlight.id}`} type="text" placeholder="e.g. 42" value={activeHighlight.episodeNumber ?? ''} onChange={e => updatePodcastLink(activeHighlight.id, 'episodeNumber', e.target.value)} />
@@ -6185,6 +6324,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<option value={activeLesson.audioEmbedUrl}>{activeLesson.audioEmbedUrl}</option> <option value={activeLesson.audioEmbedUrl}>{activeLesson.audioEmbedUrl}</option>
)} )}
</select> </select>
<p className="admin-field-help">Leave this as &quot;No audio&quot; to fill it in automatically: once this lane&apos;s season number and the Episode # below appear in your Spotify feed, the lesson uses that episode&apos;s audio.</p>
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label htmlFor={`sl-released-${activeLesson.id}`}>Release Date (optional)</label> <label htmlFor={`sl-released-${activeLesson.id}`}>Release Date (optional)</label>
@@ -6316,6 +6456,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
)} )}
{/* EPISODE SCRIPTS */} {/* EPISODE SCRIPTS */}
{adminView === 'podcast:season-check' && <SeasonCheckAdmin onNavigate={setAdminView} />}
{adminView === 'podcast:workflow' && <PodcastWorkflowAdmin laneA={{ title: form.seriesTitle }} laneB={{ title: form.secondSeries?.title ?? '' }} />} {adminView === 'podcast:workflow' && <PodcastWorkflowAdmin laneA={{ title: form.seriesTitle }} laneB={{ title: form.secondSeries?.title ?? '' }} />}
{adminView === 'podcast:scripts' && ( {adminView === 'podcast:scripts' && (
<section className="admin-panel-section"> <section className="admin-panel-section">
+28 -2
View File
@@ -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 } interface ResendStatus { available: boolean; reason?: string; broadcast?: { id: string; name: string; subject: string; sentAt: string | null; dashboardUrl: string } | null }
export interface DashboardData { export interface DashboardData {
seasonCheck: { total: number; errors: number; warnings: number; general: number; lanes: { lane: string; title: string; season: string; errors: number; warnings: number }[] } | null
trends: { visits: Trend; subscribers: Trend; studySignups: Trend } trends: { visits: Trend; subscribers: Trend; studySignups: Trend }
traffic: { topPages: { path: string; hits: number }[]; topReferrers: { referrer: string; count: number }[] } traffic: { topPages: { path: string; hits: number }[]; topReferrers: { referrer: string; count: number }[] }
reengagement: { t1: number; t2: number; t3: number; recentlySent: number; remindersEnabled: number } reengagement: { t1: number; t2: number; t3: number; recentlySent: number; remindersEnabled: number }
@@ -13,7 +14,7 @@ export interface DashboardData {
lastActivityAt: string | null lastActivityAt: string | null
} }
latestEpisode: { available: boolean; title?: string; episode?: string; season?: string; publishedAt?: string | null; daysAgo?: number | null; stale?: boolean; staleAfterDays?: number } latestEpisode: { available: boolean; title?: string; episode?: string; season?: string; publishedAt?: string | null; daysAgo?: number | null; stale?: boolean; staleAfterDays?: number }
nuggets: { uploadsThisWeek: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] } nuggets: { uploadsThisWeek: number; pending: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] }
health: { health: {
resendConfigured: boolean resendConfigured: boolean
lastBackup: { ok: boolean; at: string | null; error: string | null; file: string | null } lastBackup: { ok: boolean; at: string | null; error: string | null; file: string | null }
@@ -90,8 +91,9 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN
const attention: { key: string; label: string; tone: 'alert' | 'ok'; action?: () => void; actionLabel?: string }[] = [] const attention: { key: string; label: string; tone: 'alert' | 'ok'; action?: () => void; actionLabel?: string }[] = []
if (data) { 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.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.nuggets.pending > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.pending} Nuggets upload${data.nuggets.pending === 1 ? '' : 's'} waiting in the workflow`, tone: 'alert', action: () => onNavigate('nuggets:workflow'), actionLabel: 'Open workflow' })
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' }) 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' })
if (!data.health.lastBackup.ok) attention.push({ key: 'backup', label: 'Last data backup failed', tone: 'alert' }) if (!data.health.lastBackup.ok) attention.push({ key: 'backup', label: 'Last data backup failed', tone: 'alert' })
if (data.health.dataDirWritable === false) attention.push({ key: 'disk', label: 'Data directory is not writable', tone: 'alert' }) if (data.health.dataDirWritable === false) attention.push({ key: 'disk', label: 'Data directory is not writable', tone: 'alert' })
@@ -137,6 +139,30 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN
</div> </div>
)} )}
{data.seasonCheck && data.seasonCheck.lanes.length > 0 && (
<>
<h3 className="admin-dashboard-section-title">Season check</h3>
<div className="admin-dashboard-grid">
{data.seasonCheck.lanes.map(l => {
const issues = l.errors + l.warnings
return (
<div key={l.lane} className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{issues === 0 ? '✓' : issues}</div>
<div className="admin-dashboard-card-label">Lane {l.lane}{l.title ? ` — ${l.title}` : ''}</div>
<div className="admin-dashboard-card-sub">
{l.season ? `Season ${l.season}` : 'No season number set'} · {issues === 0 ? 'all set' : `${issues} to fix`}
</div>
<button type="button" className="admin-dashboard-card-action" onClick={() => onNavigate('podcast:season-check')}>Season Check →</button>
</div>
)
})}
</div>
{data.seasonCheck.general > 0 && (
<p className="admin-stats-note">Plus {data.seasonCheck.general} thing{data.seasonCheck.general === 1 ? '' : 's'} that apply to every lane (such as scripts with no season number).</p>
)}
</>
)}
<h3 className="admin-dashboard-section-title">Trends</h3> <h3 className="admin-dashboard-section-title">Trends</h3>
<div className="admin-dashboard-grid"> <div className="admin-dashboard-grid">
<TrendCard label="Real visits (7 days)" trend={data.trends.visits} onClick={() => onNavigate('analytics')} actionLabel="Analytics" /> <TrendCard label="Real visits (7 days)" trend={data.trends.visits} onClick={() => onNavigate('analytics')} actionLabel="Analytics" />
+3
View File
@@ -71,6 +71,9 @@ export interface PodcastFeaturedLink {
// Which series this highlight belongs to: Lane A (primary), Lane B (second), or general. // Which series this highlight belongs to: Lane A (primary), Lane B (second), or general.
// Missing = primary (older highlights were all about the one running series). // Missing = primary (older highlights were all about the one running series).
lane?: 'primary' | 'second' | 'general' lane?: 'primary' | 'second' | 'general'
// When true the highlight stays hidden on the public site until its season + episode number
// appear in the Spotify feed (the lane's season number is set on the Episode Workflow page).
holdUntilReleased?: boolean
title: string title: string
episodeNumber?: string episodeNumber?: string
summary: string summary: string