Add manual outreach funnel clearing
This commit is contained in:
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; }
|
||||
|
||||
Reference in new issue
Block a user