Expand admin dashboard with trends, health, study and email insights; v2.8.0
Publish Container / build (push) Successful in 15m53s
Publish Container / build (push) Successful in 15m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dad0e5691e
commit
134a2c403e
7 files changed
+645
-2
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.7.1",
|
||||
"version": "2.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -51,6 +51,7 @@ import { register as registerAdminAuth } from './server/routes/admin-auth.js'
|
||||
import { register as registerAdminContent, runScheduledSeriesTransition } from './server/routes/admin-content.js'
|
||||
import { register as registerAdminAssets } from './server/routes/admin-assets.js'
|
||||
import { register as registerAdminBackup } from './server/routes/admin-backup.js'
|
||||
import { register as registerAdminDashboard } from './server/routes/admin-dashboard.js'
|
||||
import { register as registerStudyAuth } from './server/routes/study-auth.js'
|
||||
import { register as registerStudyData } from './server/routes/study-data.js'
|
||||
import { register as registerStudyAccount } from './server/routes/study-account.js'
|
||||
@@ -104,6 +105,7 @@ registerAdminAuth(app)
|
||||
registerAdminContent(app)
|
||||
registerAdminAssets(app)
|
||||
registerAdminBackup(app)
|
||||
registerAdminDashboard(app)
|
||||
registerStudyAuth(app)
|
||||
registerStudyData(app)
|
||||
registerStudyAccount(app)
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Resend } from 'resend'
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { state } from '../state.js'
|
||||
import { getStorageStatus, loadUserProgress } from '../data.js'
|
||||
import { getStudyCatalog } from '../study-helpers.js'
|
||||
import { resendRetry } from '../email.js'
|
||||
import { fetchAllEpisodes } from './episodes.js'
|
||||
import { loadHistory } from './nuggets-host-upload.js'
|
||||
import { BACKUP_DIR } from '../config.js'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const STALE_FEED_DAYS = 21
|
||||
|
||||
function dayKey(offsetDays) {
|
||||
return new Date(Date.now() - offsetDays * DAY_MS).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function countSince(items, getDate, fromMs, toMs = Infinity) {
|
||||
let n = 0
|
||||
for (const item of items) {
|
||||
const t = new Date(getDate(item) ?? 0).getTime()
|
||||
if (t >= fromMs && t < toMs) n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function dailySeries(items, getDate, days) {
|
||||
const buckets = new Map()
|
||||
for (let i = days - 1; i >= 0; i--) buckets.set(dayKey(i), 0)
|
||||
for (const item of items) {
|
||||
const key = String(getDate(item) ?? '').slice(0, 10)
|
||||
if (buckets.has(key)) buckets.set(key, buckets.get(key) + 1)
|
||||
}
|
||||
return [...buckets.values()]
|
||||
}
|
||||
|
||||
function topCounts(values, limit) {
|
||||
const counts = {}
|
||||
for (const v of values) if (v) counts[v] = (counts[v] ?? 0) + 1
|
||||
return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, limit)
|
||||
}
|
||||
|
||||
function buildTrends() {
|
||||
const now = Date.now()
|
||||
const week = 7 * DAY_MS
|
||||
const subscribers = state.contactSubmissions.filter(s => s.subscribe === true)
|
||||
const studyUsers = state.studyUsers
|
||||
const realByDay = state.hitStats.byDayReal ?? {}
|
||||
const visitSeries = Array.from({ length: 14 }, (_, i) => realByDay[dayKey(13 - i)] ?? 0)
|
||||
return {
|
||||
visits: {
|
||||
thisWeek: visitSeries.slice(7).reduce((a, b) => a + b, 0),
|
||||
lastWeek: visitSeries.slice(0, 7).reduce((a, b) => a + b, 0),
|
||||
series: visitSeries,
|
||||
},
|
||||
subscribers: {
|
||||
thisWeek: countSince(subscribers, s => s.submittedAt, now - week),
|
||||
lastWeek: countSince(subscribers, s => s.submittedAt, now - 2 * week, now - week),
|
||||
series: dailySeries(subscribers, s => s.submittedAt, 14),
|
||||
},
|
||||
studySignups: {
|
||||
thisWeek: countSince(studyUsers, u => u.createdAt, now - week),
|
||||
lastWeek: countSince(studyUsers, u => u.createdAt, now - 2 * week, now - week),
|
||||
series: dailySeries(studyUsers, u => u.createdAt, 14),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildTopTraffic() {
|
||||
const cutoff = dayKey(6)
|
||||
const rows = (state.visitorStats.recentVisits ?? []).filter(r => !r.isBot && r.at && r.at >= cutoff)
|
||||
return {
|
||||
topPages: topCounts(rows.map(r => r.path ?? '/'), 5).map(([path, hits]) => ({ path, hits })),
|
||||
topReferrers: topCounts(rows.map(r => r.referrer), 5).map(([referrer, count]) => ({ referrer, count })),
|
||||
}
|
||||
}
|
||||
|
||||
function buildReengagement() {
|
||||
const now = Date.now()
|
||||
const tiers = { t1: 0, t2: 0, t3: 0 }
|
||||
let recentlySent = 0
|
||||
for (const user of state.studyUsers) {
|
||||
const sent = user.reengagementSentAt && typeof user.reengagementSentAt === 'object' ? user.reengagementSentAt : {}
|
||||
for (const at of Object.values(sent)) {
|
||||
if (now - new Date(at).getTime() <= 30 * DAY_MS) recentlySent++
|
||||
}
|
||||
if (!user.studyRemindersEnabled || !user.lastLoginAt || !(user.enrolledStudySlugs?.length)) continue
|
||||
const days = (now - new Date(user.lastLoginAt).getTime()) / DAY_MS
|
||||
if (days >= 30) tiers.t3++
|
||||
else if (days >= 14) tiers.t2++
|
||||
else if (days >= 7) tiers.t1++
|
||||
}
|
||||
return { ...tiers, recentlySent, remindersEnabled: state.studyUsers.filter(u => u.studyRemindersEnabled).length }
|
||||
}
|
||||
|
||||
async function buildStudyProgress() {
|
||||
const catalog = getStudyCatalog()
|
||||
const perStudy = new Map(catalog.map(s => [s.slug, { slug: s.slug, title: s.title, enrolled: 0, lessonsCompleted: 0, certificates: 0 }]))
|
||||
let lastActivityAt = null
|
||||
await Promise.all(state.studyUsers.map(async user => {
|
||||
for (const slug of user.enrolledStudySlugs ?? []) {
|
||||
const row = perStudy.get(slug)
|
||||
if (row) row.enrolled++
|
||||
}
|
||||
try {
|
||||
const progress = await loadUserProgress(user.id)
|
||||
for (const [slug, data] of Object.entries(progress.byStudy ?? {})) {
|
||||
const row = perStudy.get(slug)
|
||||
if (row) row.lessonsCompleted += (data.completedSectionIds ?? []).length
|
||||
}
|
||||
if (progress.updatedAt && (!lastActivityAt || progress.updatedAt > lastActivityAt)) lastActivityAt = progress.updatedAt
|
||||
} catch { /* ignore unreadable progress */ }
|
||||
}))
|
||||
for (const cert of state.studyCertificates ?? []) {
|
||||
const row = perStudy.get(cert.studySlug)
|
||||
if (row) row.certificates++
|
||||
}
|
||||
return {
|
||||
studies: [...perStudy.values()].filter(s => s.enrolled > 0 || s.lessonsCompleted > 0 || s.certificates > 0),
|
||||
certificatesTotal: (state.studyCertificates ?? []).length,
|
||||
lastActivityAt,
|
||||
}
|
||||
}
|
||||
|
||||
async function buildLatestEpisode() {
|
||||
try {
|
||||
const episodes = await fetchAllEpisodes()
|
||||
const latest = episodes[0]
|
||||
if (!latest) return { available: false }
|
||||
const published = new Date(latest.pubDate)
|
||||
const valid = !isNaN(published.getTime())
|
||||
const daysAgo = valid ? Math.floor((Date.now() - published.getTime()) / DAY_MS) : null
|
||||
return {
|
||||
available: true,
|
||||
title: latest.title,
|
||||
episode: latest.episode,
|
||||
season: latest.season,
|
||||
publishedAt: valid ? published.toISOString() : null,
|
||||
daysAgo,
|
||||
stale: daysAgo !== null && daysAgo > STALE_FEED_DAYS,
|
||||
staleAfterDays: STALE_FEED_DAYS,
|
||||
}
|
||||
} catch {
|
||||
return { available: false }
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpcomingEvents() {
|
||||
const today = dayKey(0)
|
||||
const events = (state.calendarEvents ?? [])
|
||||
.filter(ev => ev.date && ev.date >= today && !ev.completed)
|
||||
.map(ev => ({ id: ev.id, kind: 'event', type: ev.type, title: ev.title, date: ev.date, startTime: ev.startTime ?? null }))
|
||||
const episodes = (state.podcastChecklist?.episodes ?? [])
|
||||
.filter(ep => ep.datePublished && ep.datePublished >= today)
|
||||
.map(ep => ({
|
||||
id: `ep-${ep.episodeNumber ?? ep.title}`,
|
||||
kind: 'episode',
|
||||
type: 'episode',
|
||||
title: `${ep.episodeNumber ? `Ep. ${ep.episodeNumber}: ` : ''}${ep.title || 'Untitled'}`,
|
||||
date: ep.datePublished,
|
||||
startTime: null,
|
||||
}))
|
||||
return [...events, ...episodes].sort((a, b) => a.date.localeCompare(b.date)).slice(0, 3)
|
||||
}
|
||||
|
||||
async function buildNuggets() {
|
||||
const { entries } = await loadHistory()
|
||||
const weekAgo = Date.now() - 7 * DAY_MS
|
||||
return {
|
||||
uploadsThisWeek: entries.filter(e => new Date(e.uploadedAt).getTime() >= weekAgo).length,
|
||||
latest: entries.slice(0, 3).map(e => ({ hostName: e.hostName, episodeName: e.episodeName, uploadedAt: e.uploadedAt })),
|
||||
entries: entries.slice(0, 10),
|
||||
}
|
||||
}
|
||||
|
||||
async function buildHealth() {
|
||||
let backupCount = null
|
||||
try { backupCount = (await readdir(BACKUP_DIR)).filter(f => f.endsWith('.json')).length } catch { /* no backup dir yet */ }
|
||||
const storage = await getStorageStatus().catch(() => null)
|
||||
return {
|
||||
resendConfigured: Boolean(process.env.RESEND_API_KEY),
|
||||
lastBackup: state.lastBackupStatus,
|
||||
backupCount,
|
||||
dataDirWritable: storage ? storage.writable?.ok === true : null,
|
||||
writes: { hitStats: state.lastHitStatsWrite, visitorStats: state.lastVisitorStatsWrite },
|
||||
serverStartedAt: state.serverStartedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// The Resend SDK (6.x) exposes no open/click metrics for broadcasts, so this reports
|
||||
// the most recent send and links to the Resend dashboard for engagement numbers.
|
||||
async function buildLastBroadcast() {
|
||||
if (!process.env.RESEND_API_KEY) return { available: false, reason: 'RESEND_API_KEY is not configured.' }
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const { data: list, error } = await resendRetry(() => resend.broadcasts.list())
|
||||
if (error) throw new Error(error.message)
|
||||
const sent = (list?.data ?? []).filter(b => b.status === 'sent')
|
||||
.sort((a, b) => new Date(b.sent_at ?? 0).getTime() - new Date(a.sent_at ?? 0).getTime())[0]
|
||||
if (!sent) return { available: true, broadcast: null }
|
||||
const { data, error: getError } = await resendRetry(() => resend.broadcasts.get(sent.id))
|
||||
if (getError || !data) throw new Error(getError?.message ?? 'Broadcast not found.')
|
||||
return {
|
||||
available: true,
|
||||
broadcast: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
subject: data.subject,
|
||||
sentAt: data.sent_at ?? sent.sent_at ?? null,
|
||||
dashboardUrl: `https://resend.com/broadcasts/${data.id}`,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
return { available: false, reason: err instanceof Error ? err.message : 'Could not reach Resend.' }
|
||||
}
|
||||
}
|
||||
|
||||
function buildActivity(nuggetEntries) {
|
||||
const items = []
|
||||
for (const c of state.contactSubmissions) items.push({ at: c.submittedAt, kind: 'contact', text: `${c.name || c.email} sent a contact message` })
|
||||
for (const q of state.questions) items.push({ at: q.submittedAt, kind: 'question', text: `${q.firstName || 'Someone'} asked a question` })
|
||||
for (const c of state.studyComments) items.push({ at: c.createdAt, kind: 'comment', text: `${c.displayName || 'A student'} commented on a study` })
|
||||
for (const u of state.studyUsers) items.push({ at: u.createdAt, kind: 'study-user', text: `${u.displayName || u.username} created a study account` })
|
||||
for (const c of state.studyCertificates ?? []) items.push({ at: c.issuedAt, kind: 'certificate', text: `${c.displayName} earned a certificate: ${c.studyTitle}` })
|
||||
for (const e of nuggetEntries) items.push({ at: e.uploadedAt, kind: 'nuggets', text: `${e.hostName || 'A host'} uploaded "${e.episodeName}"` })
|
||||
return items
|
||||
.filter(i => i.at && !isNaN(new Date(i.at).getTime()))
|
||||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||||
.slice(0, 12)
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin-dashboard', requireAdminAuth, async (_req, res) => {
|
||||
try {
|
||||
const [studyProgress, latestEpisode, nuggets, health, lastBroadcast] = await Promise.all([
|
||||
buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(),
|
||||
])
|
||||
const comments = state.studyComments ?? []
|
||||
res.json({
|
||||
trends: buildTrends(),
|
||||
traffic: buildTopTraffic(),
|
||||
reengagement: buildReengagement(),
|
||||
studyProgress,
|
||||
latestEpisode,
|
||||
upcoming: buildUpcomingEvents(),
|
||||
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, latest: nuggets.latest },
|
||||
health,
|
||||
lastBroadcast,
|
||||
comments: {
|
||||
pending: comments.filter(c => c.isApproved !== true).length,
|
||||
thisWeek: countSince(comments, c => c.createdAt, Date.now() - 7 * DAY_MS),
|
||||
},
|
||||
activity: buildActivity(nuggets.entries),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[admin-dashboard] failed:', err)
|
||||
res.status(500).json({ message: 'Failed to build dashboard data.' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -108,7 +108,7 @@ async function saveUploadHosts(data) {
|
||||
}
|
||||
|
||||
// ── History helpers ───────────────────────────────────────────────────────
|
||||
async function loadHistory() {
|
||||
export async function loadHistory() {
|
||||
try {
|
||||
const raw = await readFile(NUGGETS_UPLOAD_HISTORY_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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'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 & 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'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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user