diff --git a/apps/dashboard/src/features/outreach/outreach-page.tsx b/apps/dashboard/src/features/outreach/outreach-page.tsx index 6fdda3d..37ab8f0 100644 --- a/apps/dashboard/src/features/outreach/outreach-page.tsx +++ b/apps/dashboard/src/features/outreach/outreach-page.tsx @@ -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(null); const [deletingCandidate, setDeletingCandidate] = useState(null); const [actionNotice, setActionNotice] = useState(null); + const [selectedTab, setSelectedTab] = useState('active'); + const activeTabRef = useRef(null); + const rejectionsTabRef = useRef(null); + + const selectTab = (tab: OutreachTab, focus = false) => { + setSelectedTab(tab); + if (focus) (tab === 'active' ? activeTabRef : rejectionsTabRef).current?.focus(); + }; + const handleTabKeyDown = (event: React.KeyboardEvent) => { + 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,30 +272,79 @@ export function OutreachPage() {

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.

-
-
-
Potential posts

Active queue

- - {data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''} - Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s - -
- {data.candidates.length === 0 ? ( -
The active queue is clear.New potential posts will appear here as discovery finds them.
- ) : ( -
    - {data.candidates.map((candidate) => ( - { clearMutation.reset(); deleteMutation.reset(); setActionNotice(null); setDeletingCandidate(selected); }} - onEdit={(selected) => { clearMutation.reset(); editMutation.reset(); setActionNotice(null); setEditingCandidate(selected); }} - /> - ))} -
- )} -
+
+ + +
+ + {selectedTab === 'active' ? ( +
+
+
Potential posts

Active queue

+ + {data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''} + Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s + +
+ {data.candidates.length === 0 ? ( +
The active queue is clear.New potential posts will appear here as discovery finds them.
+ ) : ( +
    + {data.candidates.map((candidate) => ( + { clearMutation.reset(); deleteMutation.reset(); setActionNotice(null); setDeletingCandidate(selected); }} + onEdit={(selected) => { clearMutation.reset(); editMutation.reset(); setActionNotice(null); setEditingCandidate(selected); }} + /> + ))} +
+ )} +
+ ) : ( +
+
+
Terminal screening outcomes

Rejections

+ Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s +
+ {data.rejectionReasons.length === 0 ? ( +
No rejection reasons recorded.Rejected candidates will be grouped here as the funnel screens them.
+ ) : ( +
    + {data.rejectionReasons.map(({ reason, count }) => ( +
  1. {reason}{count}
  2. + ))} +
+ )} +
+ )} {isConfirmingClear ? ( total + stats.byStatus[status], 0), stages, outcomes: { sent: stats.byStatus.sent, rejected: stats.byStatus.rejected }, + rejectionReasons: stats.rejectionReasons, oldestActiveAt: queue.getOldestActiveAt(), candidates, }; diff --git a/tests/dashboard/app.test.tsx b/tests/dashboard/app.test.tsx index 6ede59a..aa9b6a0 100644 --- a/tests/dashboard/app.test.tsx +++ b/tests/dashboard/app.test.tsx @@ -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: [], })), diff --git a/tests/web/outreach-route.test.ts b/tests/web/outreach-route.test.ts index 2984b3e..e8a8837 100644 --- a/tests/web/outreach-route.test.ts +++ b/tests/web/outreach-route.test.ts @@ -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/',