feat(dashboard): add performance and operations slices

This commit is contained in:
jjbrunton committed 2026-08-08 15:10:52 +01:00
1 parent 20fa83f245
commit 5917b63755
13 files changed
+1059

No files matched your search

@@ -0,0 +1,106 @@
export type ConsoleLevel = 'debug' | 'info' | 'warn' | 'error';
export type ConsoleLevelFilter = 'all' | ConsoleLevel;
export interface ConsoleEntry {
id: string;
occurredAt: string;
level: ConsoleLevel;
message: string;
data?: unknown;
}
export interface LiveConsoleState {
capacity: number;
entries: readonly ConsoleEntry[];
pendingEntries: readonly ConsoleEntry[];
isPaused: boolean;
isAutoScrollEnabled: boolean;
level: ConsoleLevelFilter;
search: string;
}
export type LiveConsoleAction =
| { type: 'append'; entry: ConsoleEntry }
| { type: 'set-paused'; paused: boolean }
| { type: 'set-auto-scroll'; enabled: boolean }
| { type: 'set-level'; level: ConsoleLevelFilter }
| { type: 'set-search'; search: string };
function boundedAppend(
entries: readonly ConsoleEntry[],
entry: ConsoleEntry,
capacity: number,
): readonly ConsoleEntry[] {
if (entries.some(({ id }) => id === entry.id)) return entries;
return [...entries, entry].slice(-capacity);
}
export function createLiveConsoleState(capacity = 500): LiveConsoleState {
if (!Number.isInteger(capacity) || capacity <= 0) {
throw new RangeError('capacity must be a positive integer');
}
return {
capacity,
entries: [],
pendingEntries: [],
isPaused: false,
isAutoScrollEnabled: true,
level: 'all',
search: '',
};
}
export function reduceLiveConsoleState(
state: LiveConsoleState,
action: LiveConsoleAction,
): LiveConsoleState {
switch (action.type) {
case 'append': {
if (
state.entries.some(({ id }) => id === action.entry.id) ||
state.pendingEntries.some(({ id }) => id === action.entry.id)
) return state;
return state.isPaused
? { ...state, pendingEntries: boundedAppend(state.pendingEntries, action.entry, state.capacity) }
: { ...state, entries: boundedAppend(state.entries, action.entry, state.capacity) };
}
case 'set-paused':
if (action.paused === state.isPaused) return state;
return action.paused
? { ...state, isPaused: true }
: {
...state,
isPaused: false,
entries: [...state.entries, ...state.pendingEntries].slice(-state.capacity),
pendingEntries: [],
};
case 'set-auto-scroll':
return { ...state, isAutoScrollEnabled: action.enabled };
case 'set-level':
return { ...state, level: action.level };
case 'set-search':
return { ...state, search: action.search };
}
}
function searchableData(data: unknown): string {
if (data === undefined) return '';
try {
return JSON.stringify(data);
} catch {
return '';
}
}
export function selectVisibleConsoleEntries(
state: LiveConsoleState,
): readonly ConsoleEntry[] {
const search = state.search.trim().toLocaleLowerCase();
return state.entries.filter((entry) => {
if (state.level !== 'all' && entry.level !== state.level) return false;
if (search.length === 0) return true;
return `${entry.message} ${searchableData(entry.data)}`
.toLocaleLowerCase()
.includes(search);
});
}
@@ -0,0 +1,54 @@
import { z } from 'zod';
import {
ActivityResponseSchema,
ChatSyncStatusSchema,
} from '../../../../../packages/api-contracts/src/index.js';
export const RawLogEntrySchema = z.strictObject({
timestamp: z.number(),
level: z.union([z.literal('debug'), z.literal('info'), z.literal('warn'), z.literal('error')]),
message: z.string(),
data: z.unknown().optional(),
});
export const RawLogsResponseSchema = z.array(RawLogEntrySchema);
export type RawLogEntry = z.infer<typeof RawLogEntrySchema>;
async function parseResponse<T>(
response: Response,
schema: { parse(value: unknown): T },
): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const message =
typeof payload === 'object' &&
payload !== null &&
'error' in payload &&
typeof payload.error === 'string'
? payload.error
: `Request failed (${response.status})`;
throw new Error(message);
}
return schema.parse(payload);
}
export function createOperationsApi(fetchImplementation?: typeof fetch) {
function request(path: string): Promise<Response> {
return (fetchImplementation ?? globalThis.fetch)(path);
}
return {
async getActivity() {
return parseResponse(await request('/api/activity'), ActivityResponseSchema);
},
async getLogs(limit = 200) {
return parseResponse(
await request(`/api/logs?limit=${limit}`),
RawLogsResponseSchema,
);
},
async getSyncStatus() {
return parseResponse(await request('/api/chats/sync'), ChatSyncStatusSchema);
},
};
}
export const operationsApi = createOperationsApi();
@@ -0,0 +1,179 @@
import { useEffect, useReducer, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { ActivityItem, ChatSyncStatus, DashboardEvent } from '../../../../../packages/api-contracts/src/index.js';
import {
createLiveConsoleState,
reduceLiveConsoleState,
selectVisibleConsoleEntries,
type ConsoleEntry,
type ConsoleLevelFilter,
} from './live-console-model.js';
import { useActivityQuery, useLogsQuery, useSyncStatusQuery } from './operations-queries.js';
import {
createBrowserEventSource,
createDashboardRealtimeClient,
queryKeysForDashboardEvent,
type RealtimeConnectionSnapshot,
} from './realtime-client.js';
import './operations.css';
function formatTimestamp(timestamp: number | string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(typeof timestamp === 'number' ? timestamp : new Date(timestamp));
}
function ActivityView({ items }: { items: readonly ActivityItem[] }) {
return items.length === 0 ? (
<p className="feature-empty">No recent operator activity.</p>
) : (
<ol className="operations-activity-list">
{items.map((item) => (
<li key={`${item.timestamp}-${item.message}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<div><strong>{item.message}</strong><small>{formatTimestamp(item.timestamp)}{item.recipient ? ` · u/${item.recipient}` : ''}</small></div>
</li>
))}
</ol>
);
}
function SyncSummary({ status }: { status: ChatSyncStatus }) {
const progress = status.progress;
return (
<div className="sync-summary">
<div><span className={`pill ${status.syncing ? 'warning' : 'success'}`}>{status.syncing ? `${status.syncingKind ?? 'Unknown'} sync running` : 'Idle'}</span><strong>{status.cachedChats} cached conversations</strong></div>
{progress ? (
<div>
<div className="sync-progress-label"><span>{progress.label ?? progress.phase}</span><strong>{progress.current} of {progress.total}</strong></div>
<progress aria-label="Sync progress" max={Math.max(1, progress.total)} value={progress.current} />
</div>
) : null}
<dl className="sync-history">
<div><dt>Last delta sync</dt><dd>{status.lastDeltaSyncAt ? formatTimestamp(status.lastDeltaSyncAt) : 'Never'}</dd></div>
<div><dt>Last full sync</dt><dd>{status.lastFullSyncAt ? formatTimestamp(status.lastFullSyncAt) : 'Never'}</dd></div>
</dl>
</div>
);
}
function connectionLabel(snapshot: RealtimeConnectionSnapshot): string {
switch (snapshot.status) {
case 'connected': return 'Realtime connected';
case 'connecting': return 'Connecting to realtime updates';
case 'reconnecting': return 'Realtime reconnecting';
case 'closed': return 'Realtime closed';
case 'idle': return 'Realtime unavailable';
}
}
export interface OperationsPageProps {
realtimeEnabled?: boolean;
}
export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) {
const queryClient = useQueryClient();
const activityQuery = useActivityQuery();
const logsQuery = useLogsQuery();
const syncQuery = useSyncStatusQuery();
const [consoleState, dispatchConsole] = useReducer(
reduceLiveConsoleState,
createLiveConsoleState(500),
);
const [connection, setConnection] = useState<RealtimeConnectionSnapshot>({
status: 'idle',
isStale: true,
});
const consoleEnd = useRef<HTMLDivElement>(null);
useEffect(() => {
logsQuery.data?.forEach((entry, index) => {
dispatchConsole({
type: 'append',
entry: {
id: `initial-${entry.timestamp}-${index}`,
occurredAt: new Date(entry.timestamp).toISOString(),
level: entry.level,
message: entry.message,
data: entry.data,
},
});
});
}, [logsQuery.data]);
useEffect(() => {
if (!realtimeEnabled || typeof EventSource === 'undefined') {
setConnection({ status: 'idle', isStale: true });
return;
}
function onEvent(event: DashboardEvent): void {
if (event.type === 'log.created') {
const entry: ConsoleEntry = {
id: event.id,
occurredAt: event.occurredAt,
level: event.payload.level,
message: event.payload.message,
data: event.payload.data,
};
dispatchConsole({ type: 'append', entry });
}
for (const queryKey of queryKeysForDashboardEvent(event)) {
void queryClient.invalidateQueries({ queryKey });
}
}
const client = createDashboardRealtimeClient({
sourceFactory: createBrowserEventSource,
onEvent,
onStateChange: setConnection,
});
client.start();
return () => client.stop();
}, [queryClient, realtimeEnabled]);
const visibleEntries = selectVisibleConsoleEntries(consoleState);
useEffect(() => {
if (
consoleState.isAutoScrollEnabled &&
!consoleState.isPaused &&
typeof consoleEnd.current?.scrollIntoView === 'function'
) {
consoleEnd.current.scrollIntoView({ block: 'nearest' });
}
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused]);
return (
<div className="page operations-page">
<header className="page-header"><div><span className="eyebrow">Observability</span><h1>Operations</h1><p>Human-readable activity, background work, and raw diagnostic output in distinct views.</p></div></header>
<div aria-label="Realtime connection" className={`connection-banner ${connection.isStale ? 'stale' : ''}`} role="status">
<strong>{connectionLabel(connection)}</strong>
{connection.isStale ? <span>State may be stale</span> : <span>Events are updating live</span>}
</div>
<div className="operations-summary-grid">
<section aria-labelledby="operations-activity-heading" className="panel feature-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Readable history</span><h2 id="operations-activity-heading">Activity</h2></div></div>
{activityQuery.isPending ? <p role="status">Loading activity…</p> : activityQuery.isError ? <p role="alert">{activityQuery.error.message}</p> : <ActivityView items={activityQuery.data} />}
</section>
<section aria-labelledby="sync-jobs-heading" className="panel feature-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Background work</span><h2 id="sync-jobs-heading">Sync jobs</h2></div></div>
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <SyncSummary status={syncQuery.data} />}
</section>
</div>
<section aria-labelledby="live-console-heading" className="panel console-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Raw diagnostics</span><h2 id="live-console-heading">Live console</h2></div><span className="console-count">{consoleState.entries.length} retained</span></div>
<div className="console-controls">
<button aria-pressed={consoleState.isPaused} className="button secondary" onClick={() => dispatchConsole({ type: 'set-paused', paused: !consoleState.isPaused })} type="button">{consoleState.isPaused ? `Resume console (${consoleState.pendingEntries.length})` : 'Pause console'}</button>
<label>Log level<select aria-label="Log level" onChange={(event) => dispatchConsole({ type: 'set-level', level: event.target.value as ConsoleLevelFilter })} value={consoleState.level}><option value="all">All levels</option><option value="debug">Debug</option><option value="info">Info</option><option value="warn">Warning</option><option value="error">Error</option></select></label>
<label>Search logs<input aria-label="Search logs" onChange={(event) => dispatchConsole({ type: 'set-search', search: event.target.value })} placeholder="Message or metadata" type="search" value={consoleState.search} /></label>
<label className="checkbox-control"><input checked={consoleState.isAutoScrollEnabled} onChange={(event) => dispatchConsole({ type: 'set-auto-scroll', enabled: event.target.checked })} type="checkbox" />Auto-scroll</label>
</div>
<div aria-live="polite" className="live-console" role="log">
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => (
<article className={`console-entry ${entry.level}`} key={entry.id}><time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time><strong>{entry.level.toUpperCase()}</strong><span>{entry.message}</span></article>
))}
<div ref={consoleEnd} />
</div>
</section>
</div>
);
}
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { operationsApi } from './operations-api.js';
export const operationsQueryKeys = {
activity: ['operations', 'activity'] as const,
logs: ['operations', 'logs'] as const,
syncStatus: ['operations', 'sync-status'] as const,
};
export function useActivityQuery() {
return useQuery({ queryKey: operationsQueryKeys.activity, queryFn: operationsApi.getActivity });
}
export function useLogsQuery() {
return useQuery({ queryKey: operationsQueryKeys.logs, queryFn: () => operationsApi.getLogs(200) });
}
export function useSyncStatusQuery() {
return useQuery({ queryKey: operationsQueryKeys.syncStatus, queryFn: operationsApi.getSyncStatus });
}
@@ -0,0 +1,33 @@
.operations-page { display: grid; gap: .8rem; }
.connection-banner { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; color: #aaf0c4; background: #163a26; border: 1px solid #28593d; border-radius: .75rem; }
.connection-banner.stale { color: #ffe1a0; background: #2a2111; border-color: #59451e; }
.operations-summary-grid { display: grid; gap: .8rem; }
.operations-activity-list { display: grid; gap: .8rem; padding: 0; margin: 0; list-style: none; }
.operations-activity-list li { display: flex; gap: .75rem; }
.operations-activity-list li div { display: grid; gap: .2rem; }
.operations-activity-list small { color: var(--muted); }
.sync-summary { display: grid; gap: 1rem; }
.sync-summary > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.sync-progress-label { display: flex; justify-content: space-between; gap: .75rem; margin-bottom: .45rem; color: var(--muted); }
.sync-summary progress { width: 100%; accent-color: var(--accent); }
.sync-history { display: grid; gap: .5rem; margin: 0; }
.sync-history div { display: flex; justify-content: space-between; gap: 1rem; padding-top: .6rem; border-top: 1px solid var(--border); }
.sync-history dt { color: var(--muted); }
.sync-history dd { margin: 0; }
.console-count { color: var(--muted); font-size: .8rem; }
.console-controls { display: grid; gap: .65rem; margin-bottom: .8rem; }
.console-controls label { display: grid; gap: .3rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
.console-controls input, .console-controls select { min-height: 2.5rem; padding: .5rem .65rem; color: #e9f1ec; background: #09110e; border: 1px solid var(--border); border-radius: .55rem; }
.console-controls .checkbox-control { display: flex; flex-direction: row; align-items: center; }
.console-controls .checkbox-control input { min-height: auto; }
.live-console { height: min(30rem, 55vh); overflow: auto; padding: .65rem; color: #cbd8d1; background: #050906; border: 1px solid var(--border); border-radius: .6rem; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .78rem; }
.console-entry { display: grid; grid-template-columns: 11rem 3.5rem minmax(0, 1fr); gap: .65rem; padding: .35rem; border-bottom: 1px solid #142019; }
.console-entry time { color: #708178; }
.console-entry strong { color: #8da49a; }
.console-entry.warn strong { color: var(--warning); }
.console-entry.error strong { color: var(--danger); }
.live-console > p { color: var(--muted); text-align: center; }
@media (min-width: 56rem) {
.operations-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.console-controls { grid-template-columns: auto minmax(8rem, .45fr) minmax(14rem, 1fr) auto; align-items: end; }
}
@@ -0,0 +1,143 @@
import type { QueryKey } from '@tanstack/react-query';
import {
DashboardEventSchema,
type DashboardEvent,
} from '../../../../../packages/api-contracts/src/index.js';
export const DASHBOARD_EVENT_TYPES = [
'bot.status.changed',
'message.sent',
'message.failed',
'conversation.updated',
'reply.received',
'sync.started',
'sync.progress',
'sync.completed',
'sync.failed',
'rate-limit.changed',
'reddit.session.changed',
'log.created',
] as const;
export interface DashboardEventSource {
addEventListener(type: string, listener: (event: unknown) => void): void;
close(): void;
}
export interface RealtimeConnectionSnapshot {
status: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed';
isStale: boolean;
lastEventId?: string;
}
export interface DashboardRealtimeClient {
start(): void;
stop(): void;
getSnapshot(): RealtimeConnectionSnapshot;
}
export interface DashboardRealtimeClientOptions {
url?: string;
sourceFactory(url: string): DashboardEventSource;
onEvent(event: DashboardEvent): void;
onStateChange?(snapshot: RealtimeConnectionSnapshot): void;
onInvalidEvent?(value: unknown): void;
}
function eventData(value: unknown): string | undefined {
if (typeof value !== 'object' || value === null || !('data' in value)) return undefined;
return typeof value.data === 'string' ? value.data : undefined;
}
export function createDashboardRealtimeClient(
options: DashboardRealtimeClientOptions,
): DashboardRealtimeClient {
let source: DashboardEventSource | undefined;
let snapshot: RealtimeConnectionSnapshot = { status: 'idle', isStale: true };
function update(next: RealtimeConnectionSnapshot): void {
snapshot = next;
options.onStateChange?.(snapshot);
}
function receive(value: unknown): void {
const data = eventData(value);
if (data === undefined) {
options.onInvalidEvent?.(value);
return;
}
let decoded: unknown;
try {
decoded = JSON.parse(data);
} catch {
options.onInvalidEvent?.(data);
return;
}
const result = DashboardEventSchema.safeParse(decoded);
if (!result.success) {
options.onInvalidEvent?.(decoded);
return;
}
snapshot = { ...snapshot, lastEventId: result.data.id };
options.onEvent(result.data);
}
return {
start() {
if (source !== undefined) return;
update({ ...snapshot, status: 'connecting', isStale: true });
source = options.sourceFactory(options.url ?? '/api/events/stream');
source.addEventListener('open', () => {
update({ ...snapshot, status: 'connected', isStale: false });
});
source.addEventListener('error', () => {
update({ ...snapshot, status: 'reconnecting', isStale: true });
});
for (const type of DASHBOARD_EVENT_TYPES) source.addEventListener(type, receive);
},
stop() {
source?.close();
source = undefined;
update({ ...snapshot, status: 'closed', isStale: true });
},
getSnapshot() {
return snapshot;
},
};
}
export function queryKeysForDashboardEvent(event: DashboardEvent): readonly QueryKey[] {
switch (event.type) {
case 'bot.status.changed':
return [['dashboard', 'stats']];
case 'rate-limit.changed':
return [['dashboard', 'stats'], ['dashboard', 'timers']];
case 'message.sent':
case 'message.failed':
return [['conversations'], ['performance']];
case 'conversation.updated':
case 'reply.received':
return [['conversations'], ['operations', 'activity']];
case 'sync.started':
case 'sync.progress':
case 'sync.completed':
case 'sync.failed':
return [['operations', 'sync-status'], ['conversations'], ['media']];
case 'reddit.session.changed':
return [['reddit', 'session']];
case 'log.created':
return [];
}
}
export function createBrowserEventSource(url: string): DashboardEventSource {
const source = new EventSource(url);
return {
addEventListener(type, listener) {
source.addEventListener(type, (event) => listener(event));
},
close() {
source.close();
},
};
}
@@ -0,0 +1,73 @@
import {
DailyTrendResponseSchema,
StrategyPerformanceResponseSchema,
SubredditPerformanceResponseSchema,
SuccessfulExamplesResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
import type { z } from 'zod';
type SubredditPerformanceResponse = z.infer<typeof SubredditPerformanceResponseSchema>;
type StrategyPerformanceResponse = z.infer<typeof StrategyPerformanceResponseSchema>;
type DailyTrendResponse = z.infer<typeof DailyTrendResponseSchema>;
type SuccessfulExamplesResponse = z.infer<typeof SuccessfulExamplesResponseSchema>;
async function parseResponse<T>(
response: Response,
schema: { parse(value: unknown): T },
): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const message =
typeof payload === 'object' &&
payload !== null &&
'error' in payload &&
typeof payload.error === 'string'
? payload.error
: `Request failed (${response.status})`;
throw new Error(message);
}
return schema.parse(payload);
}
export interface PerformanceApi {
getSubredditPerformance(): Promise<SubredditPerformanceResponse>;
getStrategyPerformance(): Promise<StrategyPerformanceResponse>;
getDailyTrend(days: number): Promise<DailyTrendResponse>;
getSuccessfulExamples(): Promise<SuccessfulExamplesResponse>;
}
export function createPerformanceApi(
fetchImplementation?: typeof fetch,
) {
function request(path: string): Promise<Response> {
return (fetchImplementation ?? globalThis.fetch)(path);
}
return {
async getSubredditPerformance() {
return parseResponse(
await request('/api/analytics/by-subreddit'),
SubredditPerformanceResponseSchema,
);
},
async getStrategyPerformance() {
return parseResponse(
await request('/api/analytics/by-strategy'),
StrategyPerformanceResponseSchema,
);
},
async getDailyTrend(days: number) {
return parseResponse(
await request(`/api/analytics/trend?days=${days}`),
DailyTrendResponseSchema,
);
},
async getSuccessfulExamples() {
return parseResponse(
await request('/api/successful'),
SuccessfulExamplesResponseSchema,
);
},
} satisfies PerformanceApi;
}
export const performanceApi = createPerformanceApi();
@@ -0,0 +1,121 @@
import type { z } from 'zod';
import type {
DailyTrendResponseSchema,
StrategyPerformanceResponseSchema,
SubredditPerformanceResponseSchema,
SuccessfulExample,
} from '../../../../../packages/api-contracts/src/index.js';
import {
useDailyTrendQuery,
useStrategyPerformanceQuery,
useSubredditPerformanceQuery,
useSuccessfulExamplesQuery,
} from './performance-queries.js';
import './performance.css';
const LOW_CONFIDENCE_SAMPLE_SIZE = 20;
type SubredditPerformance = z.infer<typeof SubredditPerformanceResponseSchema>[number];
type StrategyPerformance = z.infer<typeof StrategyPerformanceResponseSchema>[number];
type DailyTrend = z.infer<typeof DailyTrendResponseSchema>[number];
function SampleSize({ totalSent }: { totalSent: number }) {
const isLowConfidence = totalSent < LOW_CONFIDENCE_SAMPLE_SIZE;
return (
<span className="sample-size">
n = {totalSent}
{isLowConfidence ? <span className="confidence-warning">Low confidence</span> : null}
</span>
);
}
function PerformanceTable({
label,
rows,
rowLabel,
}: {
label: string;
rows: readonly (SubredditPerformance | StrategyPerformance)[];
rowLabel: (row: SubredditPerformance | StrategyPerformance) => string;
}) {
return (
<div className="table-scroll">
<table aria-label={label} className="performance-table">
<thead><tr><th scope="col">Name</th><th scope="col">Responses</th><th scope="col">Rate</th><th scope="col">Sample</th></tr></thead>
<tbody>
{rows.map((row) => (
<tr key={rowLabel(row)}>
<th scope="row">
{rowLabel(row)}
{'configured' in row && row.configured ? <span className="configured-label">Configured</span> : null}
</th>
<td>{row.totalResponses}</td>
<td>{row.responseRate}%</td>
<td><SampleSize totalSent={row.totalSent} /></td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function Trend({ rows }: { rows: readonly DailyTrend[] }) {
return (
<ol aria-label="30 day response trend" className="trend-list">
{rows.map((row) => (
<li key={row.date}>
<time dateTime={row.date}>{row.date}</time>
<span className="trend-track" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.min(100, row.responseRate))}%` }} /></span>
<strong>{row.responseRate}%</strong>
<SampleSize totalSent={row.totalSent} />
</li>
))}
</ol>
);
}
function SuccessfulExamples({ examples }: { examples: readonly SuccessfulExample[] }) {
if (examples.length === 0) return <p className="feature-empty">No successful examples have been recorded yet.</p>;
return (
<div className="example-grid">
{examples.map((example) => {
const source = example.subreddit ?? 'unknown source';
return (
<article aria-label={`Successful example from ${source}`} className="successful-example" key={example.id}>
<div><span className="pill success">Response received</span><small>{source}</small></div>
{example.postTitle ? <h3>{example.postTitle}</h3> : null}
<blockquote>{example.originalMessage}</blockquote>
{example.responseSnippet ? <p><strong>Reply:</strong> {example.responseSnippet}</p> : null}
</article>
);
})}
</div>
);
}
export function PerformancePage() {
const subreddits = useSubredditPerformanceQuery();
const strategies = useStrategyPerformanceQuery();
const trend = useDailyTrendQuery(30);
const examples = useSuccessfulExamplesQuery();
const queries = [subreddits, strategies, trend, examples];
if (queries.some((query) => query.isPending)) {
return <div className="page-state" role="status">Loading performance…</div>;
}
const failed = queries.find((query) => query.isError);
if (failed?.error) {
return <div className="page-state error-state" role="alert"><strong>Performance unavailable</strong><span>{failed.error.message}</span></div>;
}
return (
<div className="page performance-page">
<header className="page-header"><div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response outcomes without hiding the sample sizes behind the percentages.</p></div></header>
<section aria-labelledby="trend-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">Last 30 days</span><h2 id="trend-heading">Response trend</h2></div></div><Trend rows={trend.data ?? []} /></section>
<div className="performance-grid">
<section aria-labelledby="subreddit-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div><PerformanceTable label="Subreddit performance" rows={subreddits.data ?? []} rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} /></section>
<section aria-labelledby="strategy-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="strategy-heading">By strategy</h2></div><PerformanceTable label="Strategy performance" rows={strategies.data ?? []} rowLabel={(row) => 'strategyId' in row ? row.strategyId : row.subreddit} /></section>
</div>
<section aria-labelledby="examples-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">What worked</span><h2 id="examples-heading">Successful examples</h2></div></div><SuccessfulExamples examples={examples.data ?? []} /></section>
</div>
);
}
@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import { performanceApi } from './performance-api.js';
export const performanceQueryKeys = {
subreddits: ['performance', 'subreddits'] as const,
strategies: ['performance', 'strategies'] as const,
trend: (days: number) => ['performance', 'trend', days] as const,
successfulExamples: ['performance', 'successful-examples'] as const,
};
export function useSubredditPerformanceQuery() {
return useQuery({
queryKey: performanceQueryKeys.subreddits,
queryFn: performanceApi.getSubredditPerformance,
});
}
export function useStrategyPerformanceQuery() {
return useQuery({
queryKey: performanceQueryKeys.strategies,
queryFn: performanceApi.getStrategyPerformance,
});
}
export function useDailyTrendQuery(days: number) {
return useQuery({
queryKey: performanceQueryKeys.trend(days),
queryFn: () => performanceApi.getDailyTrend(days),
});
}
export function useSuccessfulExamplesQuery() {
return useQuery({
queryKey: performanceQueryKeys.successfulExamples,
queryFn: performanceApi.getSuccessfulExamples,
});
}
@@ -0,0 +1,24 @@
.performance-page { display: grid; gap: .8rem; }
.feature-panel { min-width: 0; }
.performance-grid { display: grid; gap: .8rem; }
.table-scroll { overflow-x: auto; }
.performance-table { width: 100%; border-collapse: collapse; text-align: left; }
.performance-table th, .performance-table td { padding: .7rem .5rem; border-top: 1px solid var(--border); }
.performance-table thead th { border-top: 0; color: var(--muted); font-size: .75rem; text-transform: uppercase; }
.performance-table tbody th { min-width: 9rem; }
.configured-label, .confidence-warning { display: block; margin-top: .2rem; color: var(--accent); font-size: .68rem; font-weight: 750; }
.confidence-warning { color: var(--warning); }
.sample-size { white-space: nowrap; color: var(--muted); }
.trend-list { display: grid; gap: .65rem; padding: 0; margin: 0; list-style: none; }
.trend-list li { display: grid; grid-template-columns: 6.5rem minmax(5rem, 1fr) 3rem 6rem; align-items: center; gap: .75rem; }
.trend-track { height: .5rem; overflow: hidden; background: var(--border); border-radius: 999px; }
.trend-track span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
.example-grid { display: grid; gap: .75rem; }
.successful-example { padding: 1rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
.successful-example > div { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.successful-example small { color: var(--muted); }
.successful-example h3 { margin-bottom: .5rem; }
.successful-example blockquote { margin: 1rem 0; padding-left: 1rem; border-left: 2px solid var(--accent); }
.successful-example p { margin-bottom: 0; color: var(--muted); }
.feature-empty { color: var(--muted); }
@media (min-width: 64rem) { .performance-grid, .example-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }