Add recent outreach rejections stream

This commit is contained in:
jjbrunton committed 2026-08-14 15:45:57 +01:00
1 parent 0c21202253
commit 94049ed9c4
9 files changed
+170 -16

No files matched your search

@@ -1,6 +1,6 @@
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 type { OutreachCandidate, OutreachCandidateStatus, OutreachRejectedCandidate } 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';
@@ -11,7 +11,7 @@ import { DeleteCandidateDialog } from './delete-candidate-dialog.js';
import { EditMessageDialog } from './edit-message-dialog.js';
import './outreach.css';
type OutreachTab = 'active' | 'rejections';
type OutreachTab = 'active' | 'reasons' | 'recent-rejections';
const STAGES: ReadonlyArray<{
status: OutreachCandidateStatus;
@@ -152,6 +152,29 @@ function CandidateCard({
);
}
function RejectedCandidateCard({ candidate, now }: { candidate: OutreachRejectedCandidate; now: number }) {
return (
<li className="rejected-candidate">
<div className="rejected-candidate-heading">
<div className="candidate-labels">
<span className="candidate-status status-rejected">Rejected</span>
<span>r/{candidate.subreddit}</span>
<span>{candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}</span>
</div>
<time dateTime={new Date(candidate.rejectedAt).toISOString()} title={formatExactTime(candidate.rejectedAt)}>
{formatShortRelativeTime(candidate.rejectedAt, now)}
</time>
</div>
<a className="candidate-title" href={candidate.postUrl} rel="noreferrer" target="_blank">
{candidate.title} <span aria-hidden="true">↗</span>
</a>
<RedditUsernameLink username={candidate.author} />
{candidate.sourceContent ? <blockquote className="rejected-source-content">{candidate.sourceContent}</blockquote> : null}
<p className="rejection-reason"><strong>Rejected because</strong>{candidate.reason}</p>
</li>
);
}
export function OutreachPage() {
const queryClient = useQueryClient();
const query = useQuery({
@@ -177,16 +200,20 @@ export function OutreachPage() {
const [actionNotice, setActionNotice] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState<OutreachTab>('active');
const activeTabRef = useRef<HTMLButtonElement>(null);
const rejectionsTabRef = useRef<HTMLButtonElement>(null);
const reasonsTabRef = useRef<HTMLButtonElement>(null);
const recentRejectionsTabRef = useRef<HTMLButtonElement>(null);
const selectTab = (tab: OutreachTab, focus = false) => {
setSelectedTab(tab);
if (focus) (tab === 'active' ? activeTabRef : rejectionsTabRef).current?.focus();
if (focus) ({ active: activeTabRef, reasons: reasonsTabRef, 'recent-rejections': recentRejectionsTabRef }[tab]).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);
const tabs: OutreachTab[] = ['active', 'reasons', 'recent-rejections'];
const currentIndex = tabs.indexOf(selectedTab);
const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
selectTab(tabs[nextIndex], true);
};
// Single client-side clock for every displayed offset: retry countdowns tick
@@ -289,17 +316,31 @@ export function OutreachPage() {
</button>
<button
aria-controls="outreach-rejections-panel"
aria-selected={selectedTab === 'rejections'}
aria-selected={selectedTab === 'reasons'}
className="outreach-tab"
id="outreach-rejections-tab"
onClick={() => selectTab('rejections')}
id="outreach-reasons-tab"
onClick={() => selectTab('reasons')}
onKeyDown={handleTabKeyDown}
ref={rejectionsTabRef}
ref={reasonsTabRef}
role="tab"
tabIndex={selectedTab === 'rejections' ? 0 : -1}
tabIndex={selectedTab === 'reasons' ? 0 : -1}
type="button"
>
Rejections <span>{data.outcomes.rejected}</span>
Rejection reasons <span>{data.outcomes.rejected}</span>
</button>
<button
aria-controls="outreach-recent-rejections-panel"
aria-selected={selectedTab === 'recent-rejections'}
className="outreach-tab"
id="outreach-recent-rejections-tab"
onClick={() => selectTab('recent-rejections')}
onKeyDown={handleTabKeyDown}
ref={recentRejectionsTabRef}
role="tab"
tabIndex={selectedTab === 'recent-rejections' ? 0 : -1}
type="button"
>
Recent rejections <span>{data.recentRejections.length}</span>
</button>
</div>
@@ -328,8 +369,8 @@ export function OutreachPage() {
</ol>
)}
</section>
) : (
<section aria-labelledby="outreach-rejections-tab" className="rejection-section" id="outreach-rejections-panel" role="tabpanel">
) : selectedTab === 'reasons' ? (
<section aria-labelledby="outreach-reasons-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>
@@ -344,6 +385,20 @@ export function OutreachPage() {
</ol>
)}
</section>
) : (
<section aria-labelledby="outreach-recent-rejections-tab" className="rejection-section" id="outreach-recent-rejections-panel" role="tabpanel">
<div className="panel-heading">
<div><span className="eyebrow">Latest 50 terminal outcomes</span><h2>Recent rejections</h2></div>
<span className="candidate-updated">Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s</span>
</div>
{data.recentRejections.length === 0 ? (
<div className="empty-state panel"><strong>No rejected candidates yet.</strong><span>The most recent 50 screening rejections will appear here with their source and reason.</span></div>
) : (
<ol aria-label="Recent rejected outreach candidates" className="recent-rejections">
{data.recentRejections.map((candidate) => <RejectedCandidateCard candidate={candidate} key={candidate.postId} now={now} />)}
</ol>
)}
</section>
)}
{isConfirmingClear ? (
<ClearFunnelDialog
@@ -30,6 +30,14 @@
.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; }
.recent-rejections { display: grid; gap: .7rem; padding: 0; margin: 0; list-style: none; }
.rejected-candidate { display: grid; gap: .65rem; padding: .9rem 1rem; background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--warning); border-radius: .75rem; }
.rejected-candidate-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
.rejected-candidate-heading time { flex: 0 0 auto; color: var(--muted); font-size: .74rem; }
.status-rejected { color: var(--warning-text); background: #42351b; }
.rejected-source-content { margin: 0; padding: .65rem .75rem; color: #dce8e2; background: #111915; border-left: 3px solid #426655; border-radius: .35rem; white-space: pre-wrap; overflow-wrap: anywhere; }
.rejection-reason { display: grid; gap: .18rem; margin: 0; padding: .55rem .7rem; color: #e7d9b1; background: #211b12; border-left: 2px solid var(--warning); border-radius: .35rem; overflow-wrap: anywhere; }
.rejection-reason strong { color: var(--warning-text); font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
.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; }