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:
@@ -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(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user