Add active outreach funnel dashboard
This commit is contained in:
1 parent
deaf86c364
commit
f55ae7ddf8
15 files changed
+519
-1
No files matched your search
@@ -7,6 +7,7 @@ export interface NavigationItem {
|
||||
export const navigationItems: readonly NavigationItem[] = [
|
||||
{ label: 'Overview', path: '/', description: 'System health and current activity' },
|
||||
{ label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks' },
|
||||
{ label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery' },
|
||||
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history' },
|
||||
{ label: 'Media', path: '/media', description: 'Private media and sync controls' },
|
||||
{ label: 'Performance', path: '/performance', description: 'Response and strategy trends' },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PerformancePage } from '../features/performance/performance-page.js';
|
||||
import { SettingsRoute } from '../features/settings/settings-route.js';
|
||||
import { RouteErrorPage } from './route-error-page.js';
|
||||
import { AttentionPage } from '../features/attention/attention-page.js';
|
||||
import { OutreachPage } from '../features/outreach/outreach-page.js';
|
||||
|
||||
export function createDashboardRoutes(): RouteObject[] {
|
||||
return [
|
||||
@@ -18,6 +19,7 @@ export function createDashboardRoutes(): RouteObject[] {
|
||||
children: [
|
||||
{ index: true, element: <OverviewPage /> },
|
||||
{ path: 'attention', element: <AttentionPage /> },
|
||||
{ path: 'outreach', element: <OutreachPage /> },
|
||||
{ path: 'conversations', element: <ConversationRoute /> },
|
||||
{ path: 'conversations/:conversationId', element: <ConversationRoute /> },
|
||||
{ path: 'media', element: <MediaRoute /> },
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { OutreachFunnelResponseSchema } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { createDashboardRequest } from '../../api/request.js';
|
||||
|
||||
export function createOutreachApi(fetchImplementation?: typeof fetch) {
|
||||
const request = createDashboardRequest(fetchImplementation);
|
||||
return {
|
||||
async getFunnel() {
|
||||
const response = await request('/api/outreach');
|
||||
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
|
||||
: `Outreach funnel unavailable (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return OutreachFunnelResponseSchema.parse(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const outreachApi = createOutreachApi();
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
|
||||
import { outreachApi } from './outreach-api.js';
|
||||
import './outreach.css';
|
||||
|
||||
const STAGES: ReadonlyArray<{
|
||||
status: OutreachCandidateStatus;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{ status: 'discovered', label: 'Waiting', description: 'Found and queued' },
|
||||
{ status: 'screening', label: 'Screening', description: 'Checking eligibility' },
|
||||
{ status: 'ready', label: 'Ready', description: 'Message prepared' },
|
||||
{ status: 'sending', label: 'Sending', description: 'Assigned to account' },
|
||||
{ status: 'retryable', label: 'Retrying', description: 'Cooling down' },
|
||||
];
|
||||
|
||||
function statusLabel(status: OutreachCandidateStatus): string {
|
||||
return STAGES.find((stage) => stage.status === status)?.label ?? status;
|
||||
}
|
||||
|
||||
function formatWaitTime(timestamp: number, now: number): string {
|
||||
const remaining = Math.max(0, timestamp - now);
|
||||
if (remaining < 60_000) return 'in under a minute';
|
||||
if (remaining < 3_600_000) return `in ${Math.ceil(remaining / 60_000)}m`;
|
||||
if (remaining < 86_400_000) return `in ${Math.ceil(remaining / 3_600_000)}h`;
|
||||
return `in ${Math.ceil(remaining / 86_400_000)}d`;
|
||||
}
|
||||
|
||||
function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) {
|
||||
const waitingUntil = candidate.availableAt > now;
|
||||
return (
|
||||
<li className={`outreach-candidate is-${candidate.status}`}>
|
||||
<div className="candidate-status-rail" aria-hidden="true"><span /></div>
|
||||
<article>
|
||||
<div className="candidate-heading">
|
||||
<div className="candidate-labels">
|
||||
<span className={`candidate-status status-${candidate.status}`}>{statusLabel(candidate.status)}</span>
|
||||
<span>r/{candidate.subreddit}</span>
|
||||
<span>{candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}</span>
|
||||
</div>
|
||||
<time dateTime={new Date(candidate.discoveredAt).toISOString()} title={formatExactTime(candidate.discoveredAt)}>
|
||||
queued {formatShortRelativeTime(candidate.discoveredAt, now)}
|
||||
</time>
|
||||
</div>
|
||||
<a className="candidate-title" href={candidate.postUrl} rel="noreferrer" target="_blank">{candidate.title}</a>
|
||||
<div className="candidate-meta">
|
||||
<RedditUsernameLink username={candidate.author} />
|
||||
<span>{candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'}</span>
|
||||
<span>{candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'}</span>
|
||||
{waitingUntil ? <span title={formatExactTime(candidate.availableAt)}>Retry {formatWaitTime(candidate.availableAt, now)}</span> : null}
|
||||
</div>
|
||||
{candidate.note ? <p className="candidate-note"><strong>Latest note</strong>{candidate.note}</p> : null}
|
||||
{candidate.owner ? <AccountOwnerBadge owner={candidate.owner} /> : null}
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function OutreachPage() {
|
||||
const query = useQuery({
|
||||
queryKey: ['outreach', 'funnel'],
|
||||
queryFn: outreachApi.getFunnel,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
if (query.isPending) return <div className="page-state" role="status">Loading outreach funnel…</div>;
|
||||
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Outreach funnel unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
|
||||
|
||||
const { data } = query;
|
||||
const now = data.generatedAt;
|
||||
return (
|
||||
<div className="page outreach-page">
|
||||
<header className="page-header">
|
||||
<div><span className="eyebrow">Live candidate queue</span><h1>Outreach funnel</h1><p>Follow every potential post from discovery to delivery, and see exactly where the remaining queue is waiting.</p></div>
|
||||
<button className="button secondary" disabled={query.isFetching} onClick={() => void query.refetch()} type="button">{query.isFetching ? 'Refreshing…' : 'Refresh'}</button>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="active-funnel-heading" className="outreach-runway">
|
||||
<div className="runway-heading">
|
||||
<div><span className="eyebrow">Current burn-down</span><h2 id="active-funnel-heading">{data.activeCount} active {data.activeCount === 1 ? 'post' : 'posts'}</h2></div>
|
||||
<div className="runway-outcomes"><span><strong>{data.outcomes.sent}</strong> sent</span><span><strong>{data.outcomes.rejected}</strong> screened out</span></div>
|
||||
</div>
|
||||
<ol className="outreach-stages">
|
||||
{STAGES.map((stage) => (
|
||||
<li className={`stage-${stage.status}`} key={stage.status}>
|
||||
<span className="stage-marker" aria-hidden="true" />
|
||||
<div><span>{stage.label}</span><small>{stage.description}</small></div>
|
||||
<strong>{data.stages[stage.status]}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="runway-caption">Retrying posts loop back to Ready when their cooldown expires. Sent and screened-out totals cover the lifetime of this queue.</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="active-candidates-heading" className="candidate-section">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Potential posts</span><h2 id="active-candidates-heading">Active queue</h2></div><span className="candidate-updated">{data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''}Updated {formatShortRelativeTime(data.generatedAt, Date.now())}</span></div>
|
||||
{data.candidates.length === 0 ? (
|
||||
<div className="empty-state panel"><strong>The active queue is clear.</strong><span>New potential posts will appear here as discovery finds them.</span></div>
|
||||
) : (
|
||||
<ol aria-label="Active outreach candidates" className="outreach-candidates">
|
||||
{data.candidates.map((candidate) => <CandidateCard candidate={candidate} key={candidate.postId} now={now} />)}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
.outreach-page { display: grid; gap: 1rem; }
|
||||
.outreach-runway { position: relative; overflow: hidden; padding: 1.2rem; background: #101a16; border: 1px solid #355044; border-radius: 1rem; }
|
||||
.outreach-runway::before { position: absolute; inset: 0; content: ""; pointer-events: none; background: radial-gradient(circle at 12% -20%, rgba(117, 214, 157, .16), transparent 34%); }
|
||||
.runway-heading { position: relative; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1.2rem; }
|
||||
.runway-heading h2 { font-size: clamp(1.55rem, 3vw, 2.25rem); letter-spacing: -.045em; }
|
||||
.runway-outcomes { display: flex; gap: .5rem; }
|
||||
.runway-outcomes span { display: grid; min-width: 5.7rem; padding: .55rem .7rem; color: var(--muted); background: rgba(9, 17, 14, .7); border: 1px solid var(--border); border-radius: .65rem; font-size: .72rem; }
|
||||
.runway-outcomes strong { color: #e9f1ec; font-size: 1.15rem; }
|
||||
.outreach-stages { position: relative; display: grid; gap: .55rem; padding: 0; margin: 0; list-style: none; }
|
||||
.outreach-stages li { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: .75rem; padding: .75rem; background: rgba(23, 35, 30, .86); border: 1px solid var(--border); border-radius: .75rem; }
|
||||
.outreach-stages li > div { display: grid; gap: .05rem; }
|
||||
.outreach-stages li span { font-weight: 750; }
|
||||
.outreach-stages li small { color: var(--muted); }
|
||||
.outreach-stages li > strong { font-size: 1.4rem; }
|
||||
.stage-marker { width: .65rem; height: .65rem; border-radius: 50%; background: #668077; box-shadow: 0 0 0 .25rem rgba(102, 128, 119, .12); }
|
||||
.stage-screening .stage-marker { background: #86b7d7; }
|
||||
.stage-ready .stage-marker { background: var(--accent); }
|
||||
.stage-sending .stage-marker { background: #c1ef91; box-shadow: 0 0 0 .25rem rgba(193, 239, 145, .12); }
|
||||
.stage-retryable .stage-marker { background: var(--warning); }
|
||||
.runway-caption { position: relative; margin: .9rem 0 0; color: var(--muted); font-size: .78rem; }
|
||||
.candidate-section { margin-top: .5rem; }
|
||||
.candidate-updated { color: var(--muted); font-size: .8rem; }
|
||||
.outreach-candidates { display: grid; gap: .7rem; padding: 0; margin: 0; list-style: none; }
|
||||
.outreach-candidate { display: grid; grid-template-columns: .32rem minmax(0, 1fr); overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .85rem; }
|
||||
.candidate-status-rail { background: #668077; }
|
||||
.candidate-status-rail span { display: block; width: 100%; height: 3.1rem; background: #8ca49c; }
|
||||
.outreach-candidate.is-screening .candidate-status-rail span { background: #86b7d7; }
|
||||
.outreach-candidate.is-ready .candidate-status-rail span { background: var(--accent); }
|
||||
.outreach-candidate.is-sending .candidate-status-rail span { background: #c1ef91; }
|
||||
.outreach-candidate.is-retryable .candidate-status-rail span { background: var(--warning); }
|
||||
.outreach-candidate article { display: grid; gap: .7rem; min-width: 0; padding: .9rem 1rem; }
|
||||
.candidate-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
|
||||
.candidate-labels { display: flex; flex-wrap: wrap; align-items: center; gap: .45rem; color: var(--muted); font-size: .72rem; }
|
||||
.candidate-status { padding: .25rem .45rem; color: #dce8e2; background: #2b3832; border-radius: .35rem; font-weight: 800; }
|
||||
.status-screening { color: #d6edfc; background: #243a48; }
|
||||
.status-ready, .status-sending { color: #bff4d2; background: #163a26; }
|
||||
.status-retryable { color: #ffe1a0; background: #42351b; }
|
||||
.candidate-heading time { flex: 0 0 auto; color: var(--muted); font-size: .74rem; }
|
||||
.candidate-title { width: fit-content; max-width: 100%; color: #eef6f1; font-size: 1rem; font-weight: 720; line-height: 1.35; text-decoration-color: #426655; text-underline-offset: .22em; overflow-wrap: anywhere; }
|
||||
.candidate-title:hover { text-decoration-color: var(--accent); }
|
||||
.candidate-meta { display: flex; flex-wrap: wrap; gap: .4rem .8rem; color: var(--muted); font-size: .76rem; }
|
||||
.candidate-note { display: grid; gap: .18rem; margin: 0; padding: .55rem .7rem; color: #d9c99e; background: #251f13; border-left: 2px solid var(--warning); border-radius: .35rem; font-size: .78rem; overflow-wrap: anywhere; }
|
||||
.candidate-note strong { color: #ffe1a0; font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.outreach-candidate .account-owner-badge { margin-top: -.1rem; }
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.outreach-stages { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; }
|
||||
.outreach-stages::before { position: absolute; top: 50%; left: 8%; right: 8%; height: 1px; content: ""; background: #3f5a4e; }
|
||||
.outreach-stages li { position: relative; grid-template-columns: auto minmax(0, 1fr); margin-right: .55rem; }
|
||||
.outreach-stages li:last-child { margin-right: 0; }
|
||||
.outreach-stages li > strong { grid-column: 2; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.outreach-candidate.is-screening .candidate-status-rail span,
|
||||
.outreach-candidate.is-sending .candidate-status-rail span { animation: queue-pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes queue-pulse { 50% { opacity: .45; } }
|
||||
}
|
||||
Reference in new issue
Block a user