Add cross-site improvements across three phases
Phase 1 — Quick wins: - Image lazy-loading on series/resource cards - Newsletter signup added to Episodes page (before highlights) - Per-route meta tags via usePageMeta hook (title, og:title, og:description) - Breadcrumbs on study index, section, and notes pages - SVG completion checkmark badges on study section list - Analytics time-range filter (7d / 30d / 90d) in admin panel Phase 2 — Medium features: - Related episodes on archived series detail pages - Resource library two-tier filter (type + tag chips) - Global search (Fuse.js) moved below sticky header as full-width bar - Q&A anonymous upvoting with localStorage dedup + admin pin/unpin - Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics Phase 3 — Larger features: - Study section comments (auto-approve for enrolled users, admin moderation panel) - Study completion certificate (canvas render, PNG download, shareable public URL) - Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI) - Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion - Public certificate route at /certificate/:token with og meta tags - Comment moderation panel added to admin under Manage > Study Comments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+57
-10
@@ -163,13 +163,47 @@ export function register(app) {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
||||
const topPaths = Object.entries(state.hitStats.byPath)
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||
const topPathsReal = Object.entries(state.hitStats.byPathReal)
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||
const topPathsBot = Object.entries(state.hitStats.byPathBot)
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||
app.get('/api/admin-stats', requireAdminAuth, (req, res) => {
|
||||
// Time-range filter: 7d | 30d | 90d (default: all-time for aggregate, 30d for charts)
|
||||
const rangeParam = req.query.range
|
||||
const rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : 30
|
||||
const rangeLabel = rangeParam === '7d' ? '7d' : rangeParam === '90d' ? '90d' : '30d'
|
||||
|
||||
// Compute a cutoff date string (YYYY-MM-DD) for filtering daily buckets
|
||||
const cutoffDate = (() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - (rangeDays - 1))
|
||||
return d.toISOString().slice(0, 10)
|
||||
})()
|
||||
|
||||
// Filter byDayReal/byDayBot keys to only those within the range
|
||||
const filteredDayKeys = Object.keys(state.hitStats.byDayReal ?? {}).filter(day => day >= cutoffDate)
|
||||
|
||||
// Aggregate hits for the range
|
||||
const rangeRealHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayReal?.[day] ?? 0), 0)
|
||||
const rangeBotHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayBot?.[day] ?? 0), 0)
|
||||
const rangeTotalHits = rangeRealHits + rangeBotHits
|
||||
|
||||
// Filter per-path stats by range — approximate using recent visitor rows scoped to range
|
||||
const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => {
|
||||
if (!row.visitedAt) return true // include if no timestamp
|
||||
return row.visitedAt >= cutoffDate
|
||||
})
|
||||
|
||||
const rangePathCountsReal = {}
|
||||
const rangePathCountsBot = {}
|
||||
for (const row of rangeVisitorRows) {
|
||||
const p = row.path ?? '/'
|
||||
if (row.isBot) rangePathCountsBot[p] = (rangePathCountsBot[p] ?? 0) + 1
|
||||
else rangePathCountsReal[p] = (rangePathCountsReal[p] ?? 0) + 1
|
||||
}
|
||||
const topPathsReal = Object.entries(rangePathCountsReal).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([path, hits]) => ({ path, hits }))
|
||||
const topPathsBot = Object.entries(rangePathCountsBot).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([path, hits]) => ({ path, hits }))
|
||||
const topPaths = [...topPathsReal, ...topPathsBot].reduce((acc, { path, hits }) => {
|
||||
const existing = acc.find(x => x.path === path)
|
||||
if (existing) existing.hits += hits; else acc.push({ path, hits })
|
||||
return acc
|
||||
}, []).sort((a, b) => b.hits - a.hits).slice(0, 10)
|
||||
|
||||
const last7Days = buildLastNDaysStats(7)
|
||||
const last7DaysReal = last7Days.map(item => ({ day: item.day, hits: state.hitStats.byDayReal?.[item.day] ?? 0 }))
|
||||
@@ -183,7 +217,7 @@ export function register(app) {
|
||||
const botReasons = Object.entries(state.hitStats.botReasons ?? {})
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count }))
|
||||
|
||||
const recentVisitorRows = state.visitorStats.recentVisits.slice(0, 100).map(row => {
|
||||
const recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => {
|
||||
const fullVisitor = state.visitorStats.visitors[row.visitorId]
|
||||
return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] }
|
||||
})
|
||||
@@ -217,10 +251,22 @@ export function register(app) {
|
||||
const enrolledUsers = state.studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length
|
||||
const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0)
|
||||
|
||||
// Enrollment funnel: signups → first study visit → first section completed
|
||||
const funnelSignups = state.studyUsers.length
|
||||
const funnelFirstVisit = state.studyUsers.filter(u => u.firstVisitAt).length
|
||||
const funnelFirstCompletion = state.studyUsers.filter(u => u.firstCompletionAt).length
|
||||
|
||||
res.json({
|
||||
totalHits: state.hitStats.totalHits,
|
||||
realHits: state.hitStats.realHits ?? 0,
|
||||
botHits: state.hitStats.botHits ?? 0,
|
||||
realHits: rangeRealHits,
|
||||
botHits: rangeBotHits,
|
||||
rangeTotalHits,
|
||||
rangeLabel,
|
||||
rangeDays,
|
||||
// All-time totals for reference
|
||||
allTimeRealHits: state.hitStats.realHits ?? 0,
|
||||
allTimeBotHits: state.hitStats.botHits ?? 0,
|
||||
allTimeTotalHits: state.hitStats.totalHits,
|
||||
firstHitAt: state.hitStats.firstHitAt,
|
||||
lastHitAt: state.hitStats.lastHitAt,
|
||||
topPaths, topPathsReal, topPathsBot,
|
||||
@@ -273,6 +319,7 @@ export function register(app) {
|
||||
totalEnrollments,
|
||||
enrollmentsByStudy,
|
||||
users,
|
||||
funnel: { signups: funnelSignups, firstVisit: funnelFirstVisit, firstCompletion: funnelFirstCompletion },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user