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:
nmemmert
2026-06-09 09:10:29 -04:00
parent 9ad24df626
commit c4645e3475
24 changed files with 2991 additions and 76 deletions
+57 -10
View File
@@ -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 },
},
})
})