feat: add agent API and operational analytics
This commit is contained in:
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)); } }
|
||||
Reference in new issue
Block a user