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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-12 11:54:00 -04:00
1 parent 77bce2ae0c
commit 50b0e83c2b
8 files changed
+282 -5

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "siteforge", "name": "siteforge",
"version": "2.19.0", "version": "2.20.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "siteforge", "name": "siteforge",
"version": "2.19.0", "version": "2.20.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.19.0", "version": "2.20.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2
View File
@@ -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 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 { 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'
@@ -129,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)
+1 -1
View File
@@ -22,7 +22,7 @@ function refreshSnapshot() {
return inflight return inflight
} }
async function getFeedIndex() { export async function getFeedIndex() {
if (!snapshot.at) await refreshSnapshot() if (!snapshot.at) await refreshSnapshot()
else if (Date.now() - snapshot.at > SNAPSHOT_TTL_MS) refreshSnapshot() else if (Date.now() - snapshot.at > SNAPSHOT_TTL_MS) refreshSnapshot()
+10
View File
@@ -1,6 +1,7 @@
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 { 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'
@@ -219,7 +220,16 @@ 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,
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(),
+133
View File
@@ -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.' })
}
})
}
+110 -1
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,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 = <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?.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)
@@ -6334,6 +6442,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">
+23
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; 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 }
@@ -90,6 +91,7 @@ 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.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' }) 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
</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>
</>
)}
<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" />