From 8450d81e7f1da3ab311500fe97f39fb5974eb9d7 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Wed, 17 Jun 2026 13:39:35 -0400 Subject: [PATCH] Add per-episode play tracking with bar chart in admin analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server.js | 2 ++ server/config.js | 1 + server/data.js | 35 +++++++++++++++++++++ server/routes/analytics.js | 18 ++++++++++- server/state.js | 4 +++ src/AdminPage.tsx | 6 ++++ src/App.css | 8 +++++ src/components/AnalyticsPanel.tsx | 44 +++++++++++++++++++++++++++ src/components/EpisodeAudioPlayer.tsx | 13 +++++++- 9 files changed, 129 insertions(+), 2 deletions(-) diff --git a/server.js b/server.js index 6bc7f1c..1bf78f3 100644 --- a/server.js +++ b/server.js @@ -20,6 +20,7 @@ import { migrateStudyNotesIfNeeded, loadDownloadCountsFromDisk, loadQrCodesFromDisk, + loadEpisodePlaysFromDisk, loadPodcastChecklistFromDisk, createBackupSnapshot, refreshContentCaches, @@ -112,6 +113,7 @@ Promise.all([ migrateStudyNotesIfNeeded(), loadDownloadCountsFromDisk(), loadQrCodesFromDisk(), + loadEpisodePlaysFromDisk(), loadPodcastChecklistFromDisk(), refreshContentCaches(), ]) diff --git a/server/config.js b/server/config.js index 2469889..0499a5c 100644 --- a/server/config.js +++ b/server/config.js @@ -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 EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.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 DIST_DIR = path.join(ROOT_DIR, 'dist') diff --git a/server/data.js b/server/data.js index 1b6594f..8345294 100644 --- a/server/data.js +++ b/server/data.js @@ -28,6 +28,7 @@ import { UPLOADS_DIR, UPLOADS_META_FILE, DOWNLOAD_COUNTS_FILE, + EPISODE_PLAYS_FILE, EMPTY_HIT_STATS, EMPTY_VISITOR_STATS, DEFAULT_REPLY_TEMPLATES, @@ -675,6 +676,40 @@ export function incrementDownloadCount(resourceKey) { 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 ──────────────────────────────────────────────────────────────── export async function readUploadsMetadata() { diff --git a/server/routes/analytics.js b/server/routes/analytics.js index 4c8be01..f6333f8 100644 --- a/server/routes/analytics.js +++ b/server/routes/analytics.js @@ -6,7 +6,7 @@ import { MAX_RECENT_VISITS, } from '../config.js' import { state } from '../state.js' -import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType } from '../data.js' +import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType, recordEpisodePlay } from '../data.js' import { detectBot, sanitizeUserAgent, @@ -146,6 +146,14 @@ export function register(app) { 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) => { if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return @@ -324,6 +332,14 @@ export function register(app) { users, 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), }) }) } diff --git a/server/state.js b/server/state.js index c89879a..a99c3f7 100644 --- a/server/state.js +++ b/server/state.js @@ -59,6 +59,10 @@ export const state = { downloadCounts: {}, downloadCountsWritePromise: Promise.resolve(), + // episodePlays: { [title]: { total: number, byDay: { [YYYY-MM-DD]: number } } } + episodePlays: {}, + episodePlaysWritePromise: Promise.resolve(), + // qrCodes: Array<{ id, slug, label, destination, createdAt }> // qrScans: Array<{ id, qrId, slug, scannedAt, ip, userAgent }> qrCodes: [], diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index fc86afc..e0d0c2b 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -564,6 +564,12 @@ export interface AdminStats { }> funnel?: { signups: number; firstVisit: number; firstCompletion: number } } + episodePlays?: Array<{ + title: string + total: number + byDay: Record + last30Days: Array<{ day: string; plays: number }> + }> } interface AdminAsset { diff --git a/src/App.css b/src/App.css index a84406a..74e36c8 100644 --- a/src/App.css +++ b/src/App.css @@ -7896,6 +7896,14 @@ 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-list { diff --git a/src/components/AnalyticsPanel.tsx b/src/components/AnalyticsPanel.tsx index 597b5bf..3a7f26c 100644 --- a/src/components/AnalyticsPanel.tsx +++ b/src/components/AnalyticsPanel.tsx @@ -522,6 +522,50 @@ export function AnalyticsPanel({ )} + {/* Episode Plays */} + {stats.episodePlays && stats.episodePlays.length > 0 && ( + <> +
+

Episode Plays

+

Tracked each time a visitor hits play on the audio player. One count per player mount.

+
+
+ 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` }} + /> +
+
+

Total Episode Plays

{stats.episodePlays.reduce((sum: number, ep: { total: number }) => sum + ep.total, 0).toLocaleString()}

+

Episodes Played

{stats.episodePlays.length.toLocaleString()}

+

Most Played

{stats.episodePlays[0]?.title ?? '—'}

+
+ + )} + {/* Contact Summary */}

Contact Summary

diff --git a/src/components/EpisodeAudioPlayer.tsx b/src/components/EpisodeAudioPlayer.tsx index e1b0c02..6560a39 100644 --- a/src/components/EpisodeAudioPlayer.tsx +++ b/src/components/EpisodeAudioPlayer.tsx @@ -35,6 +35,7 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep const [playing, setPlaying] = useState(false) const [currentTime, setCurrentTime] = useState(0) const [duration, setDuration] = useState(0) + const playTrackedRef = useRef(false) useEffect(() => { const audio = audioRef.current @@ -61,7 +62,17 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep audio.pause() setPlaying(false) } 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(() => {}) } }