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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-12 11:56:42 -04:00
1 parent 50b0e83c2b
commit 35d3c9ca33
6 files changed
+66 -7

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "siteforge", "name": "siteforge",
"version": "2.20.0", "version": "2.20.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "siteforge", "name": "siteforge",
"version": "2.20.0", "version": "2.20.1",
"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.20.0", "version": "2.20.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1
View File
@@ -225,6 +225,7 @@ export function register(app) {
total: c.total, total: c.total,
errors: c.errors, errors: c.errors,
warnings: c.warnings, 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 })), lanes: c.lanes.map(l => ({ lane: l.lane, title: l.title, season: l.season, errors: l.errors, warnings: l.warnings })),
})) }))
.catch(() => null) .catch(() => null)
+43 -2
View File
@@ -17,6 +17,11 @@ function laneConfig(content, lane) {
title: String(content.seriesTitle ?? '').trim(), title: String(content.seriesTitle ?? '').trim(),
studySlug: String(content.seriesStudySlug ?? '').trim(), studySlug: String(content.seriesStudySlug ?? '').trim(),
highlightLane: 'primary', 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 ?? {} const second = content.secondSeries ?? {}
@@ -24,6 +29,11 @@ function laneConfig(content, lane) {
title: String(second.title ?? '').trim(), title: String(second.title ?? '').trim(),
studySlug: String(second.studySlug ?? '').trim(), studySlug: String(second.studySlug ?? '').trim(),
highlightLane: 'second', 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,
} }
} }
@@ -68,6 +78,24 @@ export async function buildSeasonCheck() {
problems.push(warn(`${noHighlightNumber.length} highlight${noHighlightNumber.length === 1 ? '' : 's'} in Lane ${lane} ${noHighlightNumber.length === 1 ? 'has' : 'have'} no episode number.`, 'podcast:highlights')) 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 // Episode numbers to show: every number any part of the season mentions, plus 1..lesson count
const numbers = new Set() const numbers = new Set()
for (let n = 1; n <= sections.length; n++) numbers.add(n) for (let n = 1; n <= sections.length; n++) numbers.add(n)
@@ -117,8 +145,21 @@ export async function buildSeasonCheck() {
}) })
} }
const total = lanes.reduce((n, l) => n + l.problems.length, 0) // Scripts are filed by season + episode, so one with no season can't be matched to an episode
return { lanes, total, errors: lanes.reduce((n, l) => n + l.errors, 0), warnings: lanes.reduce((n, l) => n + l.warnings, 0) } 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) { export function register(app) {
+15 -1
View File
@@ -2148,7 +2148,7 @@ interface SeasonCheckLane {
} }
function SeasonCheckAdmin({ onNavigate }: { onNavigate: (view: AdminView) => void }) { function SeasonCheckAdmin({ onNavigate }: { onNavigate: (view: AdminView) => void }) {
const [report, setReport] = useState<{ lanes: SeasonCheckLane[]; total: number } | null>(null) const [report, setReport] = useState<{ lanes: SeasonCheckLane[]; general: SeasonCheckProblem[]; total: number } | null>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -2173,6 +2173,20 @@ function SeasonCheckAdmin({ onNavigate }: { onNavigate: (view: AdminView) => voi
<button type="button" className="btn-admin-ghost" onClick={() => { setLoading(true); fetchReport() }} disabled={loading}>{loading ? 'Checking…' : 'Check again'}</button> <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>} {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.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 => ( {report?.lanes.map(l => (
<div key={l.lane} style={{ marginTop: 20 }}> <div key={l.lane} style={{ marginTop: 20 }}>
<h3 style={{ marginBottom: 4 }}> <h3 style={{ marginBottom: 4 }}>
+4 -1
View File
@@ -4,7 +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 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 }
@@ -157,6 +157,9 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN
) )
})} })}
</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>
)}
</> </>
)} )}