Season Check: series card, study guide, next-season handover and unseasoned-script checks; v2.20.1
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
50b0e83c2b
commit
35d3c9ca33
6 files changed
+66
-7
No files matched your search
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user