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