Add manual outreach funnel clearing

This commit is contained in:
Jamie Brunton committed 2026-08-14 09:02:18 +01:00
1 parent 6b2e1bc380
commit 4798385d8b
16 files changed
+319 -17

No files matched your search

@@ -0,0 +1,48 @@
import { Modal } from '../../components/modal.js';
interface ClearFunnelDialogProps {
clearableCount: number;
sendingCount: number;
errorMessage?: string | null;
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function ClearFunnelDialog({
clearableCount,
sendingCount,
errorMessage = null,
isPending,
onCancel,
onConfirm,
}: ClearFunnelDialogProps) {
return (
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="clear-funnel-title" onClose={onCancel}>
<span className="eyebrow">Confirm queue action</span>
<h2 id="clear-funnel-title">Clear the outreach funnel?</h2>
<p>
This will cancel {clearableCount} waiting, screening, prepared, or retrying
{clearableCount === 1 ? ' candidate' : ' candidates'}. Cleared candidates remain in the audit history and will
not be rediscovered.
</p>
{sendingCount > 0 ? (
<p><strong>{sendingCount} in-flight {sendingCount === 1 ? 'send' : 'sends'} will be left alone</strong> because delivery may already be crossing the remote-send boundary.</p>
) : null}
<p>If the bot is running, discovery may begin filling the funnel again immediately.</p>
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
<div className="dialog-actions">
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">Cancel</button>
<button
aria-busy={isPending || undefined}
className="button danger"
disabled={isPending}
onClick={onConfirm}
type="button"
>
{isPending ? 'Clearing…' : 'Clear funnel'}
</button>
</div>
</Modal>
);
}
@@ -1,4 +1,7 @@
import { OutreachFunnelResponseSchema } from '../../../../../packages/api-contracts/src/index.js';
import {
OutreachClearResponseSchema,
OutreachFunnelResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
import { createDashboardRequest } from '../../api/request.js';
export function createOutreachApi(fetchImplementation?: typeof fetch) {
@@ -16,6 +19,18 @@ export function createOutreachApi(fetchImplementation?: typeof fetch) {
}
return OutreachFunnelResponseSchema.parse(payload);
},
async clearFunnel() {
const response = await request('/api/outreach', { method: 'DELETE' });
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
: `Could not clear outreach funnel (${response.status})`;
throw new Error(message);
}
return OutreachClearResponseSchema.parse(payload);
},
};
}
@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } 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 { ClearFunnelDialog } from './clear-funnel-dialog.js';
import './outreach.css';
const STAGES: ReadonlyArray<{
@@ -121,11 +122,17 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
}
export function OutreachPage() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['outreach', 'funnel'],
queryFn: outreachApi.getFunnel,
refetchInterval: 10_000,
});
const clearMutation = useMutation({
mutationFn: outreachApi.clearFunnel,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['outreach', 'funnel'] }),
});
const [isConfirmingClear, setIsConfirmingClear] = useState(false);
// Single client-side clock for every displayed offset: retry countdowns tick
// every second while visible, everything else every 10 seconds.
@@ -156,25 +163,46 @@ export function OutreachPage() {
}
const { data } = query;
const clearableCount = data.activeCount - data.stages.sending;
const clearResult = clearMutation.data;
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>
<div className="header-actions">
<button
className="button danger"
disabled={clearableCount === 0 || clearMutation.isPending}
onClick={() => { clearMutation.reset(); setIsConfirmingClear(true); }}
type="button"
>
Clear funnel
</button>
<button
aria-busy={query.isFetching || undefined}
className="button secondary"
disabled={query.isFetching}
onClick={() => void query.refetch()}
type="button"
>
{query.isFetching ? 'Refreshing…' : 'Refresh'}
</button>
</div>
</header>
{clearResult ? (
<div className="outreach-clear-notice" role="status">
Cleared {clearResult.cleared.total} unsent {clearResult.cleared.total === 1 ? 'candidate' : 'candidates'}.
{clearResult.retainedSending > 0
? ` ${clearResult.retainedSending} in-flight ${clearResult.retainedSending === 1 ? 'send was' : 'sends were'} retained.`
: ''}
</div>
) : null}
<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 className="runway-outcomes"><span><strong>{data.outcomes.sent}</strong> sent</span><span><strong>{data.outcomes.rejected}</strong> not sent</span></div>
</div>
<ol className="outreach-stages">
{STAGES.map((stage) => (
@@ -185,7 +213,7 @@ export function OutreachPage() {
</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>
<p className="runway-caption">Retrying posts loop back to Ready when their cooldown expires. Sent and not-sent totals, including manually cleared candidates, cover the lifetime of this queue.</p>
</section>
<section aria-labelledby="active-candidates-heading" className="candidate-section">
@@ -204,6 +232,16 @@ export function OutreachPage() {
</ol>
)}
</section>
{isConfirmingClear ? (
<ClearFunnelDialog
clearableCount={clearableCount}
errorMessage={clearMutation.isError ? clearMutation.error.message : null}
isPending={clearMutation.isPending}
onCancel={() => { setIsConfirmingClear(false); clearMutation.reset(); }}
onConfirm={() => clearMutation.mutate(undefined, { onSuccess: () => setIsConfirmingClear(false) })}
sendingCount={data.stages.sending}
/>
) : null}
</div>
);
}
@@ -1,4 +1,5 @@
.outreach-page { display: grid; gap: 1rem; }
.outreach-clear-notice { padding: .75rem 1rem; color: var(--accent); background: rgba(117, 214, 157, .08); border: 1px solid rgba(117, 214, 157, .3); border-radius: .7rem; }
.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; }