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

+9
View File
@@ -7,6 +7,7 @@ import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, Stud
import { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel'
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
import { AdminDashboardExtras } from './components/AdminDashboardExtras'
import { useAutosave } from './hooks/useAutosave'
import QRCode from 'qrcode'
import InboxPanel, { type InboxSubmission } from './InboxPanel'
@@ -3385,6 +3386,14 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
</div>
</div>
<AdminDashboardExtras
publishedAt={publishState.publishedAt}
draftUpdatedAt={publishState.draftUpdatedAt}
isDirty={isDirty}
onNavigate={view => navigateTo(view as AdminView)}
onRequestPublish={() => setPublishConfirmOpen(true)}
/>
<div className="admin-dashboard-grid">
<div className={`admin-dashboard-card${unansweredCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
<div className="admin-dashboard-card-value">{unansweredCount}</div>
+97
View File
@@ -12992,3 +12992,100 @@
.cmp-rows li input[type="checkbox"] { flex-shrink: 0; margin-top: 0.15rem; }
.cmp-selectall { opacity: 0.85; font-weight: 600; }
.cmp-selectall label { display: inline-flex; gap: 0.4rem; align-items: center; cursor: pointer; }
/* ── Dashboard extras ── */
.admin-dashboard-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-top: 1.25rem;
padding: 0.85rem 1.1rem;
border: 1px solid rgba(201,168,76,0.45);
background: rgba(201,168,76,0.08);
border-radius: 0.5rem;
font-size: 0.88rem;
}
.admin-dashboard-quick {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1.25rem;
}
.admin-dashboard-quick-label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgba(240,234,216,0.55);
margin-right: 0.25rem;
}
.admin-dashboard-attention {
margin-top: 1.25rem;
padding: 1rem 1.25rem;
border: 1px solid rgba(201,168,76,0.4);
background: rgba(201,168,76,0.06);
border-radius: 0.5rem;
}
.admin-dashboard-attention ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.admin-dashboard-attention li {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
font-size: 0.88rem;
}
.admin-dashboard-section-title {
margin: 1.75rem 0 0;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgba(240,234,216,0.55);
}
.admin-dashboard-section-title + .admin-dashboard-grid {
margin-top: 0.75rem;
}
.admin-dashboard-spark {
width: 100%;
height: 24px;
color: var(--brand-gold);
opacity: 0.8;
margin-top: 0.25rem;
}
.admin-dashboard-delta--up { color: #7fc88f; }
.admin-dashboard-delta--down { color: #e08a7a; }
.admin-dashboard-delta--flat { color: rgba(240,234,216,0.5); }
.admin-dashboard-row {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.85rem;
}
.admin-dashboard-row > span:first-child {
overflow-wrap: anywhere;
}
.admin-dashboard-stale {
margin-top: 0.4rem;
font-size: 0.8rem;
color: #e0c070;
}
+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>
</>
)}
</>
)
}