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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user