Expand admin dashboard with trends, health, study and email insights; v2.8.0
Publish Container / build (push) Successful in 15m53s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 10:19:25 -04:00
1 parent dad0e5691e
commit 134a2c403e
7 files changed
+645 -2

No files matched your search

+274
View File
@@ -0,0 +1,274 @@
import { useEffect, useState } from 'react'
interface Trend { thisWeek: number; lastWeek: number; series: number[] }
interface ResendStatus { available: boolean; reason?: string; broadcast?: { id: string; name: string; subject: string; sentAt: string | null; dashboardUrl: string } | null }
export interface DashboardData {
trends: { visits: Trend; subscribers: Trend; studySignups: Trend }
traffic: { topPages: { path: string; hits: number }[]; topReferrers: { referrer: string; count: number }[] }
reengagement: { t1: number; t2: number; t3: number; recentlySent: number; remindersEnabled: number }
studyProgress: {
studies: { slug: string; title: string; enrolled: number; lessonsCompleted: number; certificates: number }[]
certificatesTotal: number
lastActivityAt: string | null
}
latestEpisode: { available: boolean; title?: string; episode?: string; season?: string; publishedAt?: string | null; daysAgo?: number | null; stale?: boolean; staleAfterDays?: number }
upcoming: { id: string; kind: string; type: string; title: string; date: string; startTime: string | null }[]
nuggets: { uploadsThisWeek: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] }
health: {
resendConfigured: boolean
lastBackup: { ok: boolean; at: string | null; error: string | null; file: string | null }
backupCount: number | null
dataDirWritable: boolean | null
writes: { hitStats: { ok: boolean; at: string | null }; visitorStats: { ok: boolean; at: string | null } }
serverStartedAt: string
}
lastBroadcast: ResendStatus
comments: { pending: number; thisWeek: number }
activity: { at: string; kind: string; text: string }[]
}
interface Props {
publishedAt: string | null
draftUpdatedAt: string | null
isDirty: boolean
onNavigate: (view: string) => void
onRequestPublish: () => void
}
function Sparkline({ values }: { values: number[] }) {
const max = Math.max(1, ...values)
const step = 100 / Math.max(1, values.length - 1)
const points = values.map((v, i) => `${(i * step).toFixed(1)},${(22 - (v / max) * 20).toFixed(1)}`).join(' ')
return (
<svg className="admin-dashboard-spark" viewBox="0 0 100 24" preserveAspectRatio="none" aria-hidden="true">
<polyline points={points} fill="none" stroke="currentColor" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
)
}
function TrendCard({ label, trend, onClick, actionLabel }: { label: string; trend: Trend; onClick?: () => void; actionLabel?: string }) {
const { thisWeek, lastWeek } = trend
const diff = thisWeek - lastWeek
const pct = lastWeek > 0 ? Math.round((diff / lastWeek) * 100) : null
const tone = diff > 0 ? 'up' : diff < 0 ? 'down' : 'flat'
const arrow = diff > 0 ? '▲' : diff < 0 ? '▼' : '–'
const delta = pct !== null ? `${arrow} ${Math.abs(pct)}%` : diff > 0 ? `${arrow} new` : '–'
return (
<div className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{thisWeek.toLocaleString()}</div>
<div className="admin-dashboard-card-label">{label}</div>
<div className="admin-dashboard-card-sub">
<span className={`admin-dashboard-delta admin-dashboard-delta--${tone}`}>{delta}</span> vs {lastWeek.toLocaleString()} previous 7 days
</div>
<Sparkline values={trend.series} />
{onClick && <button type="button" className="admin-dashboard-card-action" onClick={onClick}>{actionLabel} →</button>}
</div>
)
}
function formatWhen(value: string | null | undefined) {
if (!value) return '—'
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
function formatDay(value: string) {
const d = new Date(`${value}T12:00:00`)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onNavigate, onRequestPublish }: Props) {
const [data, setData] = useState<DashboardData | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
let cancelled = false
fetch('/api/admin-dashboard')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load dashboard data'))))
.then(json => { if (!cancelled) setData(json as DashboardData) })
.catch(() => { if (!cancelled) setFailed(true) })
return () => { cancelled = true }
}, [])
const draftNewer = !!draftUpdatedAt && (!publishedAt || new Date(draftUpdatedAt).getTime() > new Date(publishedAt).getTime())
const unpublished = isDirty || draftNewer
const attention: { key: string; label: string; tone: 'alert' | 'ok'; action?: () => void; actionLabel?: string }[] = []
if (data) {
if (data.comments.pending > 0) attention.push({ key: 'comments', label: `${data.comments.pending} study comment${data.comments.pending === 1 ? '' : 's'} awaiting approval`, tone: 'alert', action: () => onNavigate('study-comments'), actionLabel: 'Review' })
if (data.nuggets.uploadsThisWeek > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.uploadsThisWeek} Nuggets upload${data.nuggets.uploadsThisWeek === 1 ? '' : 's'} in the last 7 days`, tone: 'alert', action: () => onNavigate('nuggets:history'), actionLabel: 'View uploads' })
if (data.latestEpisode.available && data.latestEpisode.stale) attention.push({ key: 'feed', label: `Podcast feed has no new episode in ${data.latestEpisode.daysAgo} days`, tone: 'alert', action: () => onNavigate('podcast:rss'), actionLabel: 'Check feed' })
if (!data.health.lastBackup.ok) attention.push({ key: 'backup', label: 'Last data backup failed', tone: 'alert' })
if (data.health.dataDirWritable === false) attention.push({ key: 'disk', label: 'Data directory is not writable', tone: 'alert' })
}
return (
<>
{unpublished && (
<div className="admin-dashboard-banner" role="status">
<div>
<strong>{isDirty ? 'You have unsaved edits.' : 'Draft changes are not live yet.'}</strong>
<span> Draft updated {formatWhen(draftUpdatedAt)} · Live version published {formatWhen(publishedAt)}.</span>
</div>
<button type="button" className="btn-admin-save" onClick={onRequestPublish}>Publish…</button>
</div>
)}
<div className="admin-dashboard-quick">
<span className="admin-dashboard-quick-label">Quick actions</span>
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('broadcast')}>New Broadcast</button>
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('downloads')}>Add Resource</button>
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('qr-codes')}>QR Code</button>
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('questions')}>Questions</button>
<button type="button" className="btn-admin-ghost" onClick={onRequestPublish}>Publish</button>
</div>
{failed && <p className="admin-stats-note">Couldn&apos;t load the extra dashboard insights.</p>}
{!data && !failed && <p className="admin-stats-note">Loading insights…</p>}
{data && (
<>
{attention.length > 0 && (
<div className="admin-dashboard-attention">
<h3 className="admin-dashboard-activity-heading">Needs attention</h3>
<ul>
{attention.map(item => (
<li key={item.key}>
<span>{item.label}</span>
{item.action && <button type="button" className="admin-dashboard-card-action" style={{ marginTop: 0 }} onClick={item.action}>{item.actionLabel} →</button>}
</li>
))}
</ul>
</div>
)}
<h3 className="admin-dashboard-section-title">Trends</h3>
<div className="admin-dashboard-grid">
<TrendCard label="Real visits (7 days)" trend={data.trends.visits} onClick={() => onNavigate('analytics')} actionLabel="Analytics" />
<TrendCard label="New subscribers" trend={data.trends.subscribers} onClick={() => onNavigate('subscribers')} actionLabel="Subscribers" />
<TrendCard label="New study accounts" trend={data.trends.studySignups} onClick={() => onNavigate('study-users')} actionLabel="Study users" />
<div className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{data.comments.thisWeek}</div>
<div className="admin-dashboard-card-label">Study comments (7 days)</div>
<div className="admin-dashboard-card-sub">{data.comments.pending} awaiting approval</div>
<button type="button" className="admin-dashboard-card-action" onClick={() => onNavigate('study-comments')}>Study comments →</button>
</div>
</div>
<div className="admin-dashboard-activity">
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Top pages (7 days)</h3>
{data.traffic.topPages.length === 0 ? <p className="admin-stats-note">No visits recorded yet.</p> : (
<div className="admin-dashboard-feed">
{data.traffic.topPages.map(p => (
<div key={p.path} className="admin-dashboard-feed-item admin-dashboard-row"><span>{p.path}</span><strong>{p.hits}</strong></div>
))}
</div>
)}
</div>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Top referrers (7 days)</h3>
{data.traffic.topReferrers.length === 0 ? <p className="admin-stats-note">No referrers recorded yet.</p> : (
<div className="admin-dashboard-feed">
{data.traffic.topReferrers.map(r => (
<div key={r.referrer} className="admin-dashboard-feed-item admin-dashboard-row"><span>{r.referrer}</span><strong>{r.count}</strong></div>
))}
</div>
)}
</div>
</div>
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Last broadcast</h3>
{!data.lastBroadcast.available ? <p className="admin-stats-note">{data.lastBroadcast.reason ?? 'Resend is unavailable.'}</p>
: !data.lastBroadcast.broadcast ? <p className="admin-stats-note">No broadcasts sent yet.</p> : (
<div className="admin-dashboard-feed-item">
<div className="admin-dashboard-feed-meta"><strong>{data.lastBroadcast.broadcast.subject || data.lastBroadcast.broadcast.name}</strong></div>
<div className="admin-dashboard-feed-email">Sent {formatWhen(data.lastBroadcast.broadcast.sentAt)}</div>
<a className="admin-dashboard-card-action" href={data.lastBroadcast.broadcast.dashboardUrl} target="_blank" rel="noreferrer">Opens, clicks &amp; unsubscribes in Resend →</a>
</div>
)}
</div>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Study re-engagement</h3>
<div className="admin-dashboard-feed">
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Inactive 7–13 days</span><strong>{data.reengagement.t1}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Inactive 14–29 days</span><strong>{data.reengagement.t2}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Inactive 30+ days</span><strong>{data.reengagement.t3}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Reminders sent (30 days)</span><strong>{data.reengagement.recentlySent}</strong></div>
<div className="admin-dashboard-card-sub">{data.reengagement.remindersEnabled} students have reminders on</div>
</div>
</div>
</div>
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Study progress</h3>
{data.studyProgress.studies.length === 0 ? <p className="admin-stats-note">No study activity yet.</p> : (
<div className="admin-dashboard-feed">
{data.studyProgress.studies.map(s => (
<div key={s.slug} className="admin-dashboard-feed-item">
<div className="admin-dashboard-feed-meta"><strong>{s.title}</strong></div>
<div className="admin-dashboard-feed-preview">{s.enrolled} enrolled · {s.lessonsCompleted} lessons completed · {s.certificates} certificate{s.certificates === 1 ? '' : 's'}</div>
</div>
))}
<div className="admin-dashboard-card-sub">Last study activity: {formatWhen(data.studyProgress.lastActivityAt)}</div>
</div>
)}
</div>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Latest podcast episode</h3>
{!data.latestEpisode.available ? <p className="admin-stats-note">Couldn&apos;t read the podcast feed.</p> : (
<div className="admin-dashboard-feed-item">
<div className="admin-dashboard-feed-meta"><strong>{data.latestEpisode.title}</strong></div>
<div className="admin-dashboard-feed-email">
{data.latestEpisode.season ? `S${data.latestEpisode.season} ` : ''}{data.latestEpisode.episode ? `E${data.latestEpisode.episode} · ` : ''}
{formatWhen(data.latestEpisode.publishedAt)}{data.latestEpisode.daysAgo != null ? ` (${data.latestEpisode.daysAgo} days ago)` : ''}
</div>
{data.latestEpisode.stale && <div className="admin-dashboard-stale">No new episode in over {data.latestEpisode.staleAfterDays} days.</div>}
</div>
)}
<h3 className="admin-dashboard-activity-heading" style={{ marginTop: '1rem' }}>Upcoming</h3>
{data.upcoming.length === 0 ? <p className="admin-stats-note">Nothing scheduled.</p> : (
<div className="admin-dashboard-feed">
{data.upcoming.map(ev => (
<div key={ev.id} className="admin-dashboard-feed-item admin-dashboard-row">
<span>{ev.title}</span><span className="admin-dashboard-feed-date">{formatDay(ev.date)}{ev.startTime ? ` · ${ev.startTime}` : ''}</span>
</div>
))}
</div>
)}
</div>
</div>
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">System health</h3>
<div className="admin-dashboard-feed">
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Resend API key</span><strong>{data.health.resendConfigured ? 'Configured' : 'Missing'}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Last backup</span><strong>{data.health.lastBackup.at ? `${data.health.lastBackup.ok ? '✓' : '✗'} ${formatWhen(data.health.lastBackup.at)}` : 'None this run'}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Backups on disk</span><strong>{data.health.backupCount ?? '—'}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Data directory</span><strong>{data.health.dataDirWritable === null ? '—' : data.health.dataDirWritable ? 'Writable' : 'Not writable'}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Server started</span><strong>{formatWhen(data.health.serverStartedAt)}</strong></div>
</div>
</div>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Recent activity</h3>
{data.activity.length === 0 ? <p className="admin-stats-note">No activity yet.</p> : (
<div className="admin-dashboard-feed">
{data.activity.map((item, i) => (
<div key={`${item.at}-${i}`} className="admin-dashboard-feed-item admin-dashboard-row">
<span>{item.text}</span><span className="admin-dashboard-feed-date">{formatWhen(item.at)}</span>
</div>
))}
</div>
)}
</div>
</div>
</>
)}
</>
)
}