Files
RedditBot/apps/dashboard/src/features/outreach/outreach-page.tsx
T

210 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
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 { OriginPostCard } from '../../components/origin-post.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' },
];
const KNOWN_STATUSES = new Set<string>(STAGES.map((stage) => stage.status));
function statusLabel(status: OutreachCandidateStatus): string {
const stage = STAGES.find((candidate) => candidate.status === status);
if (stage) return stage.label;
// Unknown statuses fall back to a readable neutral label instead of the raw enum.
const readable = String(status).replaceAll(/[-_]/g, ' ');
return readable.charAt(0).toUpperCase() + readable.slice(1);
}
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 PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate }) {
const preparation = candidate.preparation;
if (!preparation) return null;
const targeting = preparation.physiqueTargeting;
const strategy = preparation.scripted
? 'Scripted opener'
: preparation.strategyId ?? 'Default strategy';
return (
<section aria-label={`Prepared outreach for u/${candidate.author}`} className="candidate-preparation">
<div className="candidate-preparation-grid">
{preparation.sourceImageUrl ? (
<OriginPostCard post={{
title: candidate.title,
url: candidate.postUrl,
imageUrl: preparation.sourceImageUrl,
subreddit: candidate.subreddit,
postedAt: candidate.postCreatedAt,
}} />
) : null}
<div className="candidate-preparation-copy">
<div className="candidate-score-grid" aria-label="Preparation scoring">
<div><span>Attractiveness</span><strong>{preparation.attractivenessScore === null ? 'Not scored' : `${preparation.attractivenessScore}/100`}</strong><small>Used in the opener prompt</small></div>
<div><span>Physique</span><strong>{preparation.physiqueScore === null ? 'Not scorable' : `${preparation.physiqueScore}/10`}</strong><small>{targeting?.enabled ? `Current target ${targeting.minScore}–${targeting.maxScore}` : 'Current filter off'}</small></div>
<div><span>Detected gender</span><strong>{preparation.detectedGender}</strong><small>Image analysis</small></div>
</div>
<div className="candidate-preparation-metadata">
<span><strong>Strategy</strong> {strategy}</span>
{preparation.selectionReason ? <span><strong>Selection</strong> {preparation.selectionReason}</span> : null}
<span><strong>Tone</strong> {preparation.toneId}</span>
<span><strong>Model</strong> {preparation.modelId}</span>
</div>
</div>
</div>
<div className="candidate-prepared-message">
<span>Prepared message</span>
<blockquote>{preparation.message}</blockquote>
</div>
<details className="candidate-analysis">
<summary>Show image analysis</summary>
<pre>{preparation.imageAnalysis}</pre>
</details>
</section>
);
}
function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) {
const waitingUntil = candidate.availableAt > now;
const isKnownStatus = KNOWN_STATUSES.has(candidate.status);
return (
<li className={`outreach-candidate ${isKnownStatus ? `is-${candidate.status}` : 'is-unknown'}`}>
<div className="candidate-status-rail" aria-hidden="true"><span /></div>
<article>
<div className="candidate-heading">
<div className="candidate-labels">
<span className={`candidate-status ${isKnownStatus ? `status-${candidate.status}` : 'status-neutral'}`}>{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} <span aria-hidden="true">↗</span>
</a>
<span className="sr-only">(opens Reddit in a new tab)</span>
<div className="candidate-meta">
<RedditUsernameLink username={candidate.author} />
<span>{candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'}</span>
{candidate.physiqueScore !== null ? <span>Physique {candidate.physiqueScore}/10</span> : null}
<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}
<PreparedCandidateDetails candidate={candidate} />
</article>
</li>
);
}
export function OutreachPage() {
const query = useQuery({
queryKey: ['outreach', 'funnel'],
queryFn: outreachApi.getFunnel,
refetchInterval: 10_000,
});
// Single client-side clock for every displayed offset: retry countdowns tick
// every second while visible, everything else every 10 seconds.
const [now, setNow] = useState(() => Date.now());
const hasVisibleCountdown = (query.data?.candidates ?? []).some((candidate) => candidate.availableAt > now);
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), hasVisibleCountdown ? 1_000 : 10_000);
return () => clearInterval(interval);
}, [hasVisibleCountdown]);
if (query.isPending) return <div className="page-state" role="status"><span className="spinner" />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
aria-busy={query.isFetching || undefined}
className="button secondary"
disabled={query.isFetching}
onClick={() => void query.refetch()}
type="button"
>
{query.isFetching ? 'Refreshing…' : 'Retry'}
</button>
</div>
);
}
const { data } = query;
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
aria-busy={query.isFetching || undefined}
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, now)} · Auto-updates every 10s
</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>
);
}