diff --git a/package.json b/package.json index 9a5b7c0..0a89ed2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.7.1", + "version": "2.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index 071a458..879ebff 100644 --- a/server.js +++ b/server.js @@ -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) diff --git a/server/routes/admin-dashboard.js b/server/routes/admin-dashboard.js new file mode 100644 index 0000000..dd614ce --- /dev/null +++ b/server/routes/admin-dashboard.js @@ -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.' }) + } + }) +} diff --git a/server/routes/nuggets-host-upload.js b/server/routes/nuggets-host-upload.js index dde2402..c81ffea 100644 --- a/server/routes/nuggets-host-upload.js +++ b/server/routes/nuggets-host-upload.js @@ -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) diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 4ee506d..f897f81 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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()) + navigateTo(view as AdminView)} + onRequestPublish={() => setPublishConfirmOpen(true)} + /> +
0 ? ' admin-dashboard-card--alert' : ''}`}>
{unansweredCount}
diff --git a/src/App.css b/src/App.css index c1bb237..4cfe085 100644 --- a/src/App.css +++ b/src/App.css @@ -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; +} diff --git a/src/components/AdminDashboardExtras.tsx b/src/components/AdminDashboardExtras.tsx new file mode 100644 index 0000000..57d6181 --- /dev/null +++ b/src/components/AdminDashboardExtras.tsx @@ -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 ( + + ) +} + +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 ( +
+
{thisWeek.toLocaleString()}
+
{label}
+
+ {delta} vs {lastWeek.toLocaleString()} previous 7 days +
+ + {onClick && } +
+ ) +} + +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(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 && ( +
+
+ {isDirty ? 'You have unsaved edits.' : 'Draft changes are not live yet.'} + Draft updated {formatWhen(draftUpdatedAt)} · Live version published {formatWhen(publishedAt)}. +
+ +
+ )} + +
+ Quick actions + + + + + +
+ + {failed &&

Couldn't load the extra dashboard insights.

} + {!data && !failed &&

Loading insights…

} + + {data && ( + <> + {attention.length > 0 && ( +
+

Needs attention

+
    + {attention.map(item => ( +
  • + {item.label} + {item.action && } +
  • + ))} +
+
+ )} + +

Trends

+
+ onNavigate('analytics')} actionLabel="Analytics" /> + onNavigate('subscribers')} actionLabel="Subscribers" /> + onNavigate('study-users')} actionLabel="Study users" /> +
+
{data.comments.thisWeek}
+
Study comments (7 days)
+
{data.comments.pending} awaiting approval
+ +
+
+ +
+
+

Top pages (7 days)

+ {data.traffic.topPages.length === 0 ?

No visits recorded yet.

: ( +
+ {data.traffic.topPages.map(p => ( +
{p.path}{p.hits}
+ ))} +
+ )} +
+
+

Top referrers (7 days)

+ {data.traffic.topReferrers.length === 0 ?

No referrers recorded yet.

: ( +
+ {data.traffic.topReferrers.map(r => ( +
{r.referrer}{r.count}
+ ))} +
+ )} +
+
+ +
+
+

Last broadcast

+ {!data.lastBroadcast.available ?

{data.lastBroadcast.reason ?? 'Resend is unavailable.'}

+ : !data.lastBroadcast.broadcast ?

No broadcasts sent yet.

: ( +
+
{data.lastBroadcast.broadcast.subject || data.lastBroadcast.broadcast.name}
+
Sent {formatWhen(data.lastBroadcast.broadcast.sentAt)}
+ Opens, clicks & unsubscribes in Resend → +
+ )} +
+
+

Study re-engagement

+
+
Inactive 7–13 days{data.reengagement.t1}
+
Inactive 14–29 days{data.reengagement.t2}
+
Inactive 30+ days{data.reengagement.t3}
+
Reminders sent (30 days){data.reengagement.recentlySent}
+
{data.reengagement.remindersEnabled} students have reminders on
+
+
+
+ +
+
+

Study progress

+ {data.studyProgress.studies.length === 0 ?

No study activity yet.

: ( +
+ {data.studyProgress.studies.map(s => ( +
+
{s.title}
+
{s.enrolled} enrolled · {s.lessonsCompleted} lessons completed · {s.certificates} certificate{s.certificates === 1 ? '' : 's'}
+
+ ))} +
Last study activity: {formatWhen(data.studyProgress.lastActivityAt)}
+
+ )} +
+
+

Latest podcast episode

+ {!data.latestEpisode.available ?

Couldn't read the podcast feed.

: ( +
+
{data.latestEpisode.title}
+
+ {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)` : ''} +
+ {data.latestEpisode.stale &&
No new episode in over {data.latestEpisode.staleAfterDays} days.
} +
+ )} +

Upcoming

+ {data.upcoming.length === 0 ?

Nothing scheduled.

: ( +
+ {data.upcoming.map(ev => ( +
+ {ev.title}{formatDay(ev.date)}{ev.startTime ? ` · ${ev.startTime}` : ''} +
+ ))} +
+ )} +
+
+ +
+
+

System health

+
+
Resend API key{data.health.resendConfigured ? 'Configured' : 'Missing'}
+
Last backup{data.health.lastBackup.at ? `${data.health.lastBackup.ok ? '✓' : '✗'} ${formatWhen(data.health.lastBackup.at)}` : 'None this run'}
+
Backups on disk{data.health.backupCount ?? '—'}
+
Data directory{data.health.dataDirWritable === null ? '—' : data.health.dataDirWritable ? 'Writable' : 'Not writable'}
+
Server started{formatWhen(data.health.serverStartedAt)}
+
+
+
+

Recent activity

+ {data.activity.length === 0 ?

No activity yet.

: ( +
+ {data.activity.map((item, i) => ( +
+ {item.text}{formatWhen(item.at)} +
+ ))} +
+ )} +
+
+ + )} + + ) +}