Add outreach rejection reasons tab

This commit is contained in:
jjbrunton committed 2026-08-14 15:18:46 +01:00
1 parent 7af7b211fa
commit 0c21202253
7 files changed
+91 -3

No files matched your search

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
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';
@@ -11,6 +11,8 @@ import { DeleteCandidateDialog } from './delete-candidate-dialog.js';
import { EditMessageDialog } from './edit-message-dialog.js';
import './outreach.css';
type OutreachTab = 'active' | 'rejections';
const STAGES: ReadonlyArray<{
status: OutreachCandidateStatus;
label: string;
@@ -173,6 +175,19 @@ export function OutreachPage() {
const [editingCandidate, setEditingCandidate] = useState<OutreachCandidate | null>(null);
const [deletingCandidate, setDeletingCandidate] = useState<OutreachCandidate | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState<OutreachTab>('active');
const activeTabRef = useRef<HTMLButtonElement>(null);
const rejectionsTabRef = useRef<HTMLButtonElement>(null);
const selectTab = (tab: OutreachTab, focus = false) => {
setSelectedTab(tab);
if (focus) (tab === 'active' ? activeTabRef : rejectionsTabRef).current?.focus();
};
const handleTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'Home' && event.key !== 'End') return;
event.preventDefault();
selectTab(event.key === 'ArrowLeft' || event.key === 'Home' ? 'active' : 'rejections', true);
};
// Single client-side clock for every displayed offset: retry countdowns tick
// every second while visible, everything else every 10 seconds.
@@ -257,7 +272,39 @@ export function OutreachPage() {
<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">
<div aria-label="Outreach funnel views" className="outreach-tabs" role="tablist">
<button
aria-controls="outreach-active-panel"
aria-selected={selectedTab === 'active'}
className="outreach-tab"
id="outreach-active-tab"
onClick={() => selectTab('active')}
onKeyDown={handleTabKeyDown}
ref={activeTabRef}
role="tab"
tabIndex={selectedTab === 'active' ? 0 : -1}
type="button"
>
Active queue <span>{data.activeCount}</span>
</button>
<button
aria-controls="outreach-rejections-panel"
aria-selected={selectedTab === 'rejections'}
className="outreach-tab"
id="outreach-rejections-tab"
onClick={() => selectTab('rejections')}
onKeyDown={handleTabKeyDown}
ref={rejectionsTabRef}
role="tab"
tabIndex={selectedTab === 'rejections' ? 0 : -1}
type="button"
>
Rejections <span>{data.outcomes.rejected}</span>
</button>
</div>
{selectedTab === 'active' ? (
<section aria-labelledby="outreach-active-tab" className="candidate-section" id="outreach-active-panel" role="tabpanel">
<div className="panel-heading">
<div><span className="eyebrow">Potential posts</span><h2 id="active-candidates-heading">Active queue</h2></div>
<span className="candidate-updated">
@@ -281,6 +328,23 @@ export function OutreachPage() {
</ol>
)}
</section>
) : (
<section aria-labelledby="outreach-rejections-tab" className="rejection-section" id="outreach-rejections-panel" role="tabpanel">
<div className="panel-heading">
<div><span className="eyebrow">Terminal screening outcomes</span><h2>Rejections</h2></div>
<span className="candidate-updated">Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s</span>
</div>
{data.rejectionReasons.length === 0 ? (
<div className="empty-state panel"><strong>No rejection reasons recorded.</strong><span>Rejected candidates will be grouped here as the funnel screens them.</span></div>
) : (
<ol aria-label="Outreach rejection reasons" className="rejection-reasons">
{data.rejectionReasons.map(({ reason, count }) => (
<li key={reason}><span>{reason}</span><strong>{count}</strong></li>
))}
</ol>
)}
</section>
)}
{isConfirmingClear ? (
<ClearFunnelDialog
clearableCount={clearableCount}
@@ -20,6 +20,16 @@
.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; }
.outreach-tabs { display: flex; width: fit-content; max-width: 100%; overflow-x: auto; padding: .25rem; background: #101a16; border: 1px solid #355044; border-radius: .7rem; }
.outreach-tab { display: inline-flex; align-items: center; gap: .45rem; min-height: 2.3rem; padding: .45rem .7rem; color: var(--muted); background: transparent; border: 0; border-radius: .45rem; font: inherit; font-size: .8rem; font-weight: 750; white-space: nowrap; cursor: pointer; }
.outreach-tab[aria-selected="true"] { color: #eef6f1; background: #20362b; box-shadow: inset 0 0 0 1px #426655; }
.outreach-tab span { min-width: 1.45rem; padding: .08rem .35rem; color: #dce8e2; background: rgba(9, 17, 14, .7); border-radius: 999px; font-size: .7rem; text-align: center; }
.outreach-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.rejection-section { display: grid; gap: .75rem; }
.rejection-reasons { display: grid; gap: .55rem; padding: 0; margin: 0; list-style: none; }
.rejection-reasons li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 1rem; padding: .8rem .9rem; color: #e7d9b1; background: #211b12; border: 1px solid #4c3b1d; border-left: 3px solid var(--warning); border-radius: .65rem; }
.rejection-reasons span { overflow-wrap: anywhere; }
.rejection-reasons strong { min-width: 2rem; color: var(--warning-text); font-size: 1.1rem; text-align: right; }
.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; }
+2
View File
@@ -36,6 +36,8 @@ Prepared cards in the authenticated outreach funnel expose the exact opener, gro
The funnel header also offers a destructive, confirmed **Clear funnel** action. Its dialog names the number of cancellable candidates, explains that audit rows remain and running discovery can refill the queue, and separately identifies any in-flight sends that cannot safely be cancelled. Success is reported from the authoritative API response before the funnel query refreshes.
The outreach funnel separates the live queue from its terminal screening outcomes. Its **Rejections** tab groups durable rejected candidates by their recorded reason, so an empty queue can be diagnosed without exposing the rejected post content or treating a rejection as an operator action.
Each non-sending candidate has a confirmed **Remove** action. Prepared candidates in ready or retrying state also have **Edit message**: the modal validates the 1–2000 character limit, the server re-applies outbound safeguards, and a successful edit is shown as operator-authored attribution. Buttons are intentionally absent once the candidate is sending, while the server still enforces the state boundary against stale pages.
## State model
+4
View File
@@ -72,6 +72,10 @@ export const OutreachFunnelResponseSchema = z.strictObject({
sent: z.number().int().nonnegative(),
rejected: z.number().int().nonnegative(),
}),
rejectionReasons: z.array(z.strictObject({
reason: z.string(),
count: z.number().int().positive(),
})).default([]),
oldestActiveAt: z.number().nullable(),
candidates: z.array(OutreachCandidateSchema),
});
+1
View File
@@ -128,6 +128,7 @@ export function createOutreachRouter(ctx: WebContext): Router {
activeCount: ACTIVE_STATUSES.reduce((total, status) => total + stats.byStatus[status], 0),
stages,
outcomes: { sent: stats.byStatus.sent, rejected: stats.byStatus.rejected },
rejectionReasons: stats.rejectionReasons,
oldestActiveAt: queue.getOldestActiveAt(),
candidates,
};
+6
View File
@@ -129,6 +129,7 @@ describe('dashboard foundation', () => {
activeCount: 3,
stages: { discovered: 1, screening: 0, ready: 1, sending: 0, retryable: 1 },
outcomes: { sent: 12, rejected: 7 },
rejectionReasons: [{ reason: 'Seller signals detected', count: 5 }, { reason: 'No gender tag in title/body', count: 2 }],
oldestActiveAt: Date.parse('2026-08-09T07:00:00Z'),
candidates: [{
postId: 'candidate-1', subreddit: 'example', author: 'alice', title: '[25F] A potential post',
@@ -169,6 +170,10 @@ describe('dashboard foundation', () => {
expect(await screen.findByRole('heading', { name: 'Outreach funnel' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '3 active posts' })).toBeInTheDocument();
await user.click(screen.getByRole('tab', { name: 'Rejections 7' }));
expect(screen.getByRole('tabpanel', { name: 'Rejections 7' })).toHaveTextContent('Seller signals detected');
expect(screen.getByRole('list', { name: 'Outreach rejection reasons' })).toHaveTextContent('5');
await user.click(screen.getByRole('tab', { name: 'Active queue 3' }));
expect(screen.getByRole('list', { name: 'Active outreach candidates' })).toHaveTextContent('A potential post');
expect(screen.getAllByRole('link', { name: '[25F] A potential post' }))
.toEqual(expect.arrayContaining([
@@ -219,6 +224,7 @@ describe('dashboard foundation', () => {
activeCount: cleared ? 1 : 3,
stages: { discovered: cleared ? 0 : 2, screening: 0, ready: 0, sending: 1, retryable: 0 },
outcomes: { sent: 12, rejected: cleared ? 9 : 7 },
rejectionReasons: [{ reason: 'Cleared manually by operator', count: cleared ? 2 : 0 }].filter(({ count }) => count > 0),
oldestActiveAt: Date.now() - 60_000,
candidates: [],
})),
+2 -1
View File
@@ -32,7 +32,7 @@ describe('outreach route', () => {
byStatus: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1, sent: 2, rejected: 1 },
ready: 1,
oldestReadyAt: 2_000,
rejectionReasons: [],
rejectionReasons: [{ reason: 'Seller signals detected', count: 1 }],
})),
getOldestActiveAt: vi.fn(() => 1_000),
listActive: vi.fn(() => [{
@@ -88,6 +88,7 @@ describe('outreach route', () => {
activeCount: 6,
stages: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1 },
outcomes: { sent: 2, rejected: 1 },
rejectionReasons: [{ reason: 'Seller signals detected', count: 1 }],
candidates: [{
postId: 'post-1', status: 'sending', hasPreparedMessage: true,
postUrl: 'https://www.reddit.com/r/example/comments/post-1/hello/',