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
+110
-1
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user