feat(dashboard): add React foundation and overview

This commit is contained in:
jjbrunton committed 2026-08-08 14:53:53 +01:00
1 parent 59c3a61a88
commit 51e3e28a9b
20 files changed
+3347 -244

No files matched your search

@@ -0,0 +1,199 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
import type { BotStats, Timers } from '../../api/contracts.js';
import { ConfirmationDialog } from '../../components/confirmation-dialog.js';
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function formatTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(timestamp);
}
function MetricCard({ label, value, detail, metric }: { label: string; value: string | number; detail: string; metric: string }) {
return (
<article className="metric-card">
<span>{label}</span>
<strong data-metric={metric}>{value}</strong>
<small>{detail}</small>
</article>
);
}
function TimersPanel({ timers }: { timers: Timers }) {
const isReady = timers.nextMessageAvailableAt === null || timers.nextMessageAvailableAt <= Date.now();
return (
<section aria-labelledby="timers-heading" className="panel">
<div className="panel-heading">
<div>
<span className="eyebrow">Rate limits</span>
<h2 id="timers-heading">Sending window</h2>
</div>
<span className={isReady ? 'pill success' : 'pill warning'}>{isReady ? 'Ready' : 'Cooling down'}</span>
</div>
<dl className="definition-list">
<div>
<dt>Next message</dt>
<dd>{isReady ? 'Available now' : formatTime(timers.nextMessageAvailableAt ?? Date.now())}</dd>
</div>
<div>
<dt>Hourly allowance</dt>
<dd>{timers.messagesRemaining} messages remaining</dd>
</div>
<div>
<dt>Allowance resets</dt>
<dd>{formatTime(timers.hourlyResetAt)}</dd>
</div>
</dl>
</section>
);
}
function ActivityPanel({ stats }: { stats: BotStats }) {
return (
<section aria-labelledby="activity-heading" className="panel activity-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">Latest signals</span>
<h2 id="activity-heading">Recent activity</h2>
</div>
</div>
{stats.recentActivity.length === 0 ? (
<div className="empty-state">
<strong>No recent activity yet.</strong>
<span>New bot events will appear here.</span>
</div>
) : (
<ol className="activity-list">
{stats.recentActivity.map((item) => (
<li key={`${item.timestamp}-${item.message}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<div><strong>{item.message}</strong><small>{formatTime(item.timestamp)}</small></div>
</li>
))}
</ol>
)}
</section>
);
}
export function OverviewPage() {
const queryClient = useQueryClient();
const statsQuery = useStatsQuery();
const timersQuery = useTimersQuery();
const controlMutation = useSetRunningMutation();
const [confirmationAction, setConfirmationAction] = useState<'start' | 'stop' | null>(null);
const isRefreshing = (statsQuery.isFetching || timersQuery.isFetching) && !statsQuery.isPending;
async function refreshOverview() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: dashboardQueryKeys.stats }),
queryClient.invalidateQueries({ queryKey: dashboardQueryKeys.timers }),
]);
}
function confirmControl() {
if (!confirmationAction) return;
controlMutation.mutate(confirmationAction === 'start', {
onSuccess: () => setConfirmationAction(null),
});
}
if (statsQuery.isPending) {
return <div aria-label="Loading overview" className="page-state" role="status"><span className="spinner" />Loading overview…</div>;
}
if (statsQuery.isError) {
return (
<div className="page-state error-state" role="alert">
<strong>Overview unavailable</strong>
<span>{errorMessage(statsQuery.error)}</span>
<button className="button secondary" onClick={() => void statsQuery.refetch()} type="button">Retry overview</button>
</div>
);
}
const stats = statsQuery.data;
const action = stats.isRunning ? 'stop' : 'start';
return (
<div className="page">
<header className="page-header">
<div>
<span className="eyebrow">Control room</span>
<h1>Overview</h1>
<p>System health, sending limits, and the signals that need attention.</p>
</div>
<div className="header-actions">
<button
aria-label={isRefreshing ? 'Refreshing overview' : 'Refresh overview'}
className="button secondary"
disabled={isRefreshing}
onClick={() => void refreshOverview()}
type="button"
>
{isRefreshing ? 'Refreshing…' : 'Refresh'}
</button>
<button
aria-label={`${action === 'start' ? 'Start' : 'Stop'} bot`}
className={action === 'start' ? 'button primary' : 'button danger'}
onClick={() => setConfirmationAction(action)}
type="button"
>
{action === 'start' ? 'Start bot' : 'Stop bot'}
</button>
</div>
</header>
<section aria-label="Bot state" className="status-strip">
<div>
<span className={`status-dot ${stats.isRunning ? 'running' : 'stopped'}`} aria-hidden="true" />
<div><small>Bot status</small><strong data-bot-status>{stats.isRunning ? 'Running' : 'Stopped'}</strong></div>
</div>
{stats.dryRunMode ? <span className="pill neutral">Dry run</span> : null}
<span className="status-note">{stats.messagesThisHour} of {stats.maxMessagesPerHour} messages used this hour</span>
</section>
{stats.invitationCooldown ? (
<div className="cooldown-banner" role="status">
<strong>Reddit invitation cooldown</strong>
<span>{stats.invitationCooldown.reason}</span>
</div>
) : null}
<section aria-label="Key metrics" className="metrics-grid">
<MetricCard detail="All recorded sends" label="Messages sent" metric="messages-sent" value={stats.totalMessagesSent} />
<MetricCard detail={`${stats.responseRate}% response rate`} label="Responses" metric="responses" value={stats.totalResponses} />
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={stats.totalProcessed} />
<MetricCard detail={`Limit ${stats.maxMessagesPerHour}`} label="This hour" metric="messages-hour" value={stats.messagesThisHour} />
</section>
<div className="overview-grid">
{timersQuery.isPending ? (
<section aria-label="Loading timers" className="panel panel-loading" role="status"><span className="spinner" />Loading sending window…</section>
) : timersQuery.isError ? (
<section className="panel error-panel" role="alert">
<strong>Sending window unavailable</strong>
<span>{errorMessage(timersQuery.error)}</span>
<button className="button secondary" onClick={() => void timersQuery.refetch()} type="button">Retry timers</button>
</section>
) : (
<TimersPanel timers={timersQuery.data} />
)}
<ActivityPanel stats={stats} />
</div>
{controlMutation.isError ? <div className="toast error" role="alert">{errorMessage(controlMutation.error)}</div> : null}
{confirmationAction ? (
<ConfirmationDialog
action={confirmationAction}
isPending={controlMutation.isPending}
onCancel={() => setConfirmationAction(null)}
onConfirm={confirmControl}
/>
) : null}
</div>
);
}