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:
@@ -522,6 +522,50 @@ export function AnalyticsPanel({
|
||||
)}
|
||||
</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 */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Contact Summary</h2>
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user