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

+110 -1
View File
@@ -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 = <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) {
const normalizedContent = normalizeSiteContentForAdmin(content)
const [form, setForm] = useState<SiteContent>(normalizedContent)
@@ -6334,6 +6442,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
)}
{/* EPISODE SCRIPTS */}
{adminView === 'podcast:season-check' && <SeasonCheckAdmin onNavigate={setAdminView} />}
{adminView === 'podcast:workflow' && <PodcastWorkflowAdmin laneA={{ title: form.seriesTitle }} laneB={{ title: form.secondSeries?.title ?? '' }} />}
{adminView === 'podcast:scripts' && (
<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 }
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
</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>
<div className="admin-dashboard-grid">
<TrendCard label="Real visits (7 days)" trend={data.trends.visits} onClick={() => onNavigate('analytics')} actionLabel="Analytics" />