Add per-episode play tracking with bar chart in admin analytics

- POST /api/analytics/play records a play event (title + date) on first
  play per player mount, skipping admin sessions
- Plays persisted to data/episode-plays.json with total + byDay buckets
- Admin stats response includes episodePlays sorted by total plays
- AnalyticsPanel shows horizontal bar chart + summary cards for all
  episodes dynamically — new episodes appear automatically as played

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-06-17 13:39:35 -04:00
parent 54bc7b28da
commit 8450d81e7f
9 changed files with 129 additions and 2 deletions
+2
View File
@@ -20,6 +20,7 @@ import {
migrateStudyNotesIfNeeded, migrateStudyNotesIfNeeded,
loadDownloadCountsFromDisk, loadDownloadCountsFromDisk,
loadQrCodesFromDisk, loadQrCodesFromDisk,
loadEpisodePlaysFromDisk,
loadPodcastChecklistFromDisk, loadPodcastChecklistFromDisk,
createBackupSnapshot, createBackupSnapshot,
refreshContentCaches, refreshContentCaches,
@@ -112,6 +113,7 @@ Promise.all([
migrateStudyNotesIfNeeded(), migrateStudyNotesIfNeeded(),
loadDownloadCountsFromDisk(), loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(), loadQrCodesFromDisk(),
loadEpisodePlaysFromDisk(),
loadPodcastChecklistFromDisk(), loadPodcastChecklistFromDisk(),
refreshContentCaches(), refreshContentCaches(),
]) ])
+1
View File
@@ -37,6 +37,7 @@ export const STUDY_COMMENTS_FILE = path.join(DATA_DIR, 'study-section-comments.j
export const STUDY_CERTIFICATES_FILE = path.join(DATA_DIR, 'study-certificates.json') export const STUDY_CERTIFICATES_FILE = path.join(DATA_DIR, 'study-certificates.json')
export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json') export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json') export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json')
export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
export const DIST_DIR = path.join(ROOT_DIR, 'dist') export const DIST_DIR = path.join(ROOT_DIR, 'dist')
+35
View File
@@ -28,6 +28,7 @@ import {
UPLOADS_DIR, UPLOADS_DIR,
UPLOADS_META_FILE, UPLOADS_META_FILE,
DOWNLOAD_COUNTS_FILE, DOWNLOAD_COUNTS_FILE,
EPISODE_PLAYS_FILE,
EMPTY_HIT_STATS, EMPTY_HIT_STATS,
EMPTY_VISITOR_STATS, EMPTY_VISITOR_STATS,
DEFAULT_REPLY_TEMPLATES, DEFAULT_REPLY_TEMPLATES,
@@ -675,6 +676,40 @@ export function incrementDownloadCount(resourceKey) {
queueDownloadCountsWrite() queueDownloadCountsWrite()
} }
// ── Episode play counts ────────────────────────────────────────────────────
export function queueEpisodePlaysWrite() {
state.episodePlaysWritePromise = state.episodePlaysWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(EPISODE_PLAYS_FILE, JSON.stringify(state.episodePlays, null, 2), 'utf8')
})
.catch(err => {
console.error('[episode-plays] failed to write:', err)
})
}
export function loadEpisodePlaysFromDisk() {
return readFile(EPISODE_PLAYS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.episodePlays = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
})
.catch(() => {
state.episodePlays = {}
})
}
export function recordEpisodePlay(title) {
const today = new Date().toISOString().slice(0, 10)
if (!state.episodePlays[title]) {
state.episodePlays[title] = { total: 0, byDay: {} }
}
state.episodePlays[title].total += 1
state.episodePlays[title].byDay[today] = (state.episodePlays[title].byDay[today] ?? 0) + 1
queueEpisodePlaysWrite()
}
// ── Uploads ──────────────────────────────────────────────────────────────── // ── Uploads ────────────────────────────────────────────────────────────────
export async function readUploadsMetadata() { export async function readUploadsMetadata() {
+17 -1
View File
@@ -6,7 +6,7 @@ import {
MAX_RECENT_VISITS, MAX_RECENT_VISITS,
} from '../config.js' } from '../config.js'
import { state } from '../state.js' import { state } from '../state.js'
import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType } from '../data.js' import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType, recordEpisodePlay } from '../data.js'
import { import {
detectBot, detectBot,
sanitizeUserAgent, sanitizeUserAgent,
@@ -146,6 +146,14 @@ export function register(app) {
res.json({ ok: true, consent }) res.json({ ok: true, consent })
}) })
app.post('/api/analytics/play', (req, res) => {
if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return }
const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 200) : ''
if (!title) { res.status(400).json({ ok: false, reason: 'missing-title' }); return }
recordEpisodePlay(title)
res.json({ ok: true })
})
app.post('/api/analytics/pageview', async (req, res) => { app.post('/api/analytics/pageview', async (req, res) => {
if (isValidAdminSession(req)) { if (isValidAdminSession(req)) {
res.json({ ok: false, reason: 'admin' }); return res.json({ ok: false, reason: 'admin' }); return
@@ -324,6 +332,14 @@ export function register(app) {
users, users,
funnel: { signups: funnelSignups, firstVisit: funnelFirstVisit, firstCompletion: funnelFirstCompletion }, funnel: { signups: funnelSignups, firstVisit: funnelFirstVisit, firstCompletion: funnelFirstCompletion },
}, },
episodePlays: Object.entries(state.episodePlays)
.map(([title, data]) => ({
title,
total: data.total ?? 0,
byDay: data.byDay ?? {},
last30Days: buildLastNDaysStats(30).map(item => ({ day: item.day, plays: data.byDay?.[item.day] ?? 0 })),
}))
.sort((a, b) => b.total - a.total),
}) })
}) })
} }
+4
View File
@@ -59,6 +59,10 @@ export const state = {
downloadCounts: {}, downloadCounts: {},
downloadCountsWritePromise: Promise.resolve(), downloadCountsWritePromise: Promise.resolve(),
// episodePlays: { [title]: { total: number, byDay: { [YYYY-MM-DD]: number } } }
episodePlays: {},
episodePlaysWritePromise: Promise.resolve(),
// qrCodes: Array<{ id, slug, label, destination, createdAt }> // qrCodes: Array<{ id, slug, label, destination, createdAt }>
// qrScans: Array<{ id, qrId, slug, scannedAt, ip, userAgent }> // qrScans: Array<{ id, qrId, slug, scannedAt, ip, userAgent }>
qrCodes: [], qrCodes: [],
+6
View File
@@ -564,6 +564,12 @@ export interface AdminStats {
}> }>
funnel?: { signups: number; firstVisit: number; firstCompletion: number } funnel?: { signups: number; firstVisit: number; firstCompletion: number }
} }
episodePlays?: Array<{
title: string
total: number
byDay: Record<string, number>
last30Days: Array<{ day: string; plays: number }>
}>
} }
interface AdminAsset { interface AdminAsset {
+8
View File
@@ -7896,6 +7896,14 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.admin-stats-chart-wrap {
background: #0e0e0b;
border: 1px solid rgba(201, 168, 76, 0.15);
border-radius: 10px;
padding: 1.25rem 1.25rem 1rem;
position: relative;
}
/* ── Admin: Study Users ── */ /* ── Admin: Study Users ── */
.admin-study-users-list { .admin-study-users-list {
+44
View File
@@ -522,6 +522,50 @@ export function AnalyticsPanel({
)} )}
</div> </div>
{/* Episode Plays */}
{stats.episodePlays && stats.episodePlays.length > 0 && (
<>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Episode Plays</h2>
<p>Tracked each time a visitor hits play on the audio player. One count per player mount.</p>
</div>
<div className="admin-stats-chart-wrap" style={{ marginBottom: '1.5rem' }}>
<Bar
data={{
labels: stats.episodePlays.slice(0, 12).map((ep: { title: string; total: number }) => ep.title.length > 40 ? ep.title.slice(0, 40) + '…' : ep.title),
datasets: [{
label: 'Total Plays',
data: stats.episodePlays.slice(0, 12).map((ep: { title: string; total: number }) => ep.total),
backgroundColor: '#c9a84c',
borderColor: '#8a6e28',
borderWidth: 1,
borderRadius: 4,
}],
}}
options={{
indexAxis: 'y' as const,
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: (ctx) => ` ${ctx.parsed.x} plays` } },
},
scales: {
x: { ticks: { color: '#b0a48c', font: { size: 11 } }, grid: { color: 'rgba(42,37,24,0.6)' } },
y: { ticks: { color: '#f0ead8', font: { size: 11 } }, grid: { display: false } },
},
}}
style={{ height: `${Math.max(180, stats.episodePlays.slice(0, 12).length * 36)}px` }}
/>
</div>
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
<article><h3>Total Episode Plays</h3><p>{stats.episodePlays.reduce((sum: number, ep: { total: number }) => sum + ep.total, 0).toLocaleString()}</p></article>
<article><h3>Episodes Played</h3><p>{stats.episodePlays.length.toLocaleString()}</p></article>
<article><h3>Most Played</h3><p style={{ fontSize: '0.8rem' }}>{stats.episodePlays[0]?.title ?? '—'}</p></article>
</div>
</>
)}
{/* Contact Summary */} {/* Contact Summary */}
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Contact Summary</h2> <h2>Contact Summary</h2>
+12 -1
View File
@@ -35,6 +35,7 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0) const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0) const [duration, setDuration] = useState(0)
const playTrackedRef = useRef(false)
useEffect(() => { useEffect(() => {
const audio = audioRef.current const audio = audioRef.current
@@ -61,7 +62,17 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
audio.pause() audio.pause()
setPlaying(false) setPlaying(false)
} else { } else {
audio.play().then(() => setPlaying(true)).catch(() => {}) audio.play().then(() => {
setPlaying(true)
if (!playTrackedRef.current && title) {
playTrackedRef.current = true
fetch('/api/analytics/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
}).catch(() => {})
}
}).catch(() => {})
} }
} }