Files
Siteforge/src/components/AdminDashboardExtras.tsx
T
2026-10-12 12:02:30 -04:00

285 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
seasonCheck: { total: number; errors: number; warnings: number; general: number; lanes: { lane: string; title: string; season: string; errors: number; warnings: number }[] } | null
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 }
nuggets: { uploadsThisWeek: number; pending: 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' })
}
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.seasonCheck && data.seasonCheck.total > 0) attention.push({ key: 'season-check', label: `Season check: ${data.seasonCheck.total} thing${data.seasonCheck.total === 1 ? '' : 's'} to fix`, tone: 'alert', action: () => onNavigate('podcast:season-check'), actionLabel: 'Open Season Check' })
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.pending > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.pending} Nuggets upload${data.nuggets.pending === 1 ? '' : 's'} waiting in the workflow`, tone: 'alert', action: () => onNavigate('nuggets:workflow'), actionLabel: 'Open workflow' })
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>
)}
{data.seasonCheck && data.seasonCheck.lanes.length > 0 && (
<>
<h3 className="admin-dashboard-section-title">Season check</h3>
<div className="admin-dashboard-grid">
{data.seasonCheck.lanes.map(l => {
const issues = l.errors + l.warnings
return (
<div key={l.lane} className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{issues === 0 ? '✓' : issues}</div>
<div className="admin-dashboard-card-label">Lane {l.lane}{l.title ? ` — ${l.title}` : ''}</div>
<div className="admin-dashboard-card-sub">
{l.season ? `Season ${l.season}` : 'No season number set'} · {issues === 0 ? 'all set' : `${issues} to fix`}
</div>
<button type="button" className="admin-dashboard-card-action" onClick={() => onNavigate('podcast:season-check')}>Season Check →</button>
</div>
)
})}
</div>
{data.seasonCheck.general > 0 && (
<p className="admin-stats-note">Plus {data.seasonCheck.general} thing{data.seasonCheck.general === 1 ? '' : 's'} that apply to every lane (such as scripts with no season number).</p>
)}
</>
)}
<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>
)}
</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>
</>
)}
</>
)
}