feat: add agent API and operational analytics

This commit is contained in:
jjbrunton committed 2026-08-08 22:08:14 +01:00
1 parent 720ea0652a
commit e8b730b592
65 files changed
+2650 -1214

No files matched your search

@@ -3,6 +3,7 @@ import {
StrategyPerformanceResponseSchema,
SubredditPerformanceResponseSchema,
SuccessfulExamplesResponseSchema,
FunnelResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
import type { z } from 'zod';
import { createDashboardRequest } from '../../api/request.js';
@@ -11,6 +12,7 @@ type SubredditPerformanceResponse = z.infer<typeof SubredditPerformanceResponseS
type StrategyPerformanceResponse = z.infer<typeof StrategyPerformanceResponseSchema>;
type DailyTrendResponse = z.infer<typeof DailyTrendResponseSchema>;
type SuccessfulExamplesResponse = z.infer<typeof SuccessfulExamplesResponseSchema>;
type FunnelResponse = z.infer<typeof FunnelResponseSchema>;
async function parseResponse<T>(
response: Response,
@@ -35,6 +37,7 @@ export interface PerformanceApi {
getStrategyPerformance(): Promise<StrategyPerformanceResponse>;
getDailyTrend(days: number): Promise<DailyTrendResponse>;
getSuccessfulExamples(): Promise<SuccessfulExamplesResponse>;
getFunnel(): Promise<FunnelResponse>;
}
export function createPerformanceApi(
@@ -66,6 +69,12 @@ export function createPerformanceApi(
SuccessfulExamplesResponseSchema,
);
},
async getFunnel() {
return parseResponse(
await request('/api/analytics/funnel'),
FunnelResponseSchema,
);
},
} satisfies PerformanceApi;
}
@@ -4,12 +4,14 @@ import type {
StrategyPerformanceResponseSchema,
SubredditPerformanceResponseSchema,
SuccessfulExample,
FunnelResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
import {
useDailyTrendQuery,
useStrategyPerformanceQuery,
useSubredditPerformanceQuery,
useSuccessfulExamplesQuery,
useFunnelQuery,
} from './performance-queries.js';
import './performance.css';
@@ -17,6 +19,7 @@ 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];
type Funnel = z.infer<typeof FunnelResponseSchema>;
function SampleSize({ totalSent }: { totalSent: number }) {
const isLowConfidence = totalSent < LOW_CONFIDENCE_SAMPLE_SIZE;
@@ -28,6 +31,30 @@ function SampleSize({ totalSent }: { totalSent: number }) {
);
}
function FunnelPanel({ funnel }: { funnel: Funnel }) {
const stages = [
['Processed', funnel.processed],
['Sent', funnel.sent],
[`Mature (${funnel.responseWindowHours}h)`, funnel.mature],
['Responded', funnel.responded],
['Engaged', funnel.engaged],
['Images', funnel.images],
] as const;
return (
<section aria-labelledby="funnel-heading" className="panel feature-panel">
<div className="panel-heading"><div><span className="eyebrow">Qualified engagement</span><h2 id="funnel-heading">Outcome funnel</h2></div><span className="sample-size">{funnel.pending} awaiting maturity</span></div>
<ol className="funnel-list">
{stages.map(([label, value]) => <li key={label}><span>{label}</span><strong>{value}</strong></li>)}
</ol>
<div className="guardrail-list" aria-label="Guardrail outcomes">
<span>Declined <strong>{funnel.declined}</strong></span>
<span>Boundary <strong>{funnel.boundary}</strong></span>
<span>Upsell <strong>{funnel.upsell}</strong></span>
</div>
</section>
);
}
function PerformanceTable({
label,
rows,
@@ -40,7 +67,7 @@ function PerformanceTable({
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">Response rate</th><th scope="col">Images</th><th scope="col">Image rate</th><th scope="col">Sample</th></tr></thead>
<thead><tr><th scope="col">Name</th><th scope="col">Responses</th><th scope="col">Mature response rate</th><th scope="col">Images</th><th scope="col">Image rate</th><th scope="col">Sample</th></tr></thead>
<tbody>
{rows.map((row) => (
<tr key={rowLabel(row)}>
@@ -49,10 +76,10 @@ function PerformanceTable({
{'configured' in row && row.configured ? <span className="configured-label">Configured</span> : null}
</th>
<td>{row.totalResponses}</td>
<td>{row.responseRate}%</td>
<td>{row.responseRate}%{row.responseRateLow !== undefined && row.responseRateHigh !== undefined ? <small className="interval">95% CI {row.responseRateLow}–{row.responseRateHigh}%</small> : null}</td>
<td>{row.totalImages}</td>
<td>{row.imageRate}%</td>
<td><SampleSize totalSent={row.totalSent} /></td>
<td><SampleSize totalSent={row.totalSent} />{row.pendingSent ? <small className="interval">{row.pendingSent} pending</small> : null}</td>
</tr>
))}
</tbody>
@@ -101,7 +128,8 @@ export function PerformancePage() {
const strategies = useStrategyPerformanceQuery();
const trend = useDailyTrendQuery(30);
const examples = useSuccessfulExamplesQuery();
const queries = [subreddits, strategies, trend, examples];
const funnel = useFunnelQuery();
const queries = [subreddits, strategies, trend, examples, funnel];
if (queries.some((query) => query.isPending)) {
return <div className="page-state" role="status">Loading performance…</div>;
}
@@ -113,6 +141,7 @@ export function PerformancePage() {
return (
<div className="page performance-page">
<header className="page-header"><div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response and received-image outcomes without hiding the sample sizes behind the percentages.</p></div></header>
{funnel.data ? <FunnelPanel funnel={funnel.data} /> : null}
<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>
@@ -6,6 +6,7 @@ export const performanceQueryKeys = {
strategies: ['performance', 'strategies'] as const,
trend: (days: number) => ['performance', 'trend', days] as const,
successfulExamples: ['performance', 'successful-examples'] as const,
funnel: ['performance', 'funnel'] as const,
};
export function useSubredditPerformanceQuery() {
@@ -35,3 +36,10 @@ export function useSuccessfulExamplesQuery() {
queryFn: performanceApi.getSuccessfulExamples,
});
}
export function useFunnelQuery() {
return useQuery({
queryKey: performanceQueryKeys.funnel,
queryFn: performanceApi.getFunnel,
});
}
@@ -9,6 +9,11 @@
.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); }
.interval { display: block; margin-top: .2rem; color: var(--muted); white-space: nowrap; }
.funnel-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem; padding: 0; margin: 0; list-style: none; }
.funnel-list li { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
.guardrail-list { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .7rem; color: var(--muted); }
.guardrail-list span { padding: .45rem .65rem; border: 1px solid var(--border); border-radius: 999px; }
.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 8rem 6rem; align-items: center; gap: .75rem; }
.trend-track { height: .5rem; overflow: hidden; background: var(--border); border-radius: 999px; }
@@ -21,4 +26,4 @@
.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)); } }
@media (min-width: 64rem) { .performance-grid, .example-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .funnel-list { grid-template-columns: repeat(6, minmax(0, 1fr)); } }