feat: expand outreach targeting and messaging

This commit is contained in:
jjbrunton committed 2026-08-10 17:31:05 +01:00
1 parent 7defff422d
commit d0a8bd0e2f
40 files changed
+890 -82

No files matched your search

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useQuery } 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';
@@ -37,6 +38,52 @@ function formatWaitTime(timestamp: number, now: number): string {
return `in ${Math.ceil(remaining / 86_400_000)}d`;
}
function PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate }) {
const preparation = candidate.preparation;
if (!preparation) return null;
const targeting = preparation.physiqueTargeting;
const strategy = preparation.scripted
? 'Scripted opener'
: preparation.strategyId ?? 'Default strategy';
return (
<section aria-label={`Prepared outreach for u/${candidate.author}`} className="candidate-preparation">
<div className="candidate-preparation-grid">
{preparation.sourceImageUrl ? (
<OriginPostCard post={{
title: candidate.title,
url: candidate.postUrl,
imageUrl: preparation.sourceImageUrl,
subreddit: candidate.subreddit,
postedAt: candidate.postCreatedAt,
}} />
) : null}
<div className="candidate-preparation-copy">
<div className="candidate-score-grid" aria-label="Preparation scoring">
<div><span>Attractiveness</span><strong>{preparation.attractivenessScore === null ? 'Not scored' : `${preparation.attractivenessScore}/100`}</strong><small>Used in the opener prompt</small></div>
<div><span>Physique</span><strong>{preparation.physiqueScore === null ? 'Not scorable' : `${preparation.physiqueScore}/10`}</strong><small>{targeting?.enabled ? `Target ${targeting.minScore}–${targeting.maxScore}` : 'Range filter off'}</small></div>
<div><span>Detected gender</span><strong>{preparation.detectedGender}</strong><small>Image analysis</small></div>
</div>
<div className="candidate-preparation-metadata">
<span><strong>Strategy</strong> {strategy}</span>
{preparation.selectionReason ? <span><strong>Selection</strong> {preparation.selectionReason}</span> : null}
<span><strong>Tone</strong> {preparation.toneId}</span>
<span><strong>Model</strong> {preparation.modelId}</span>
</div>
</div>
</div>
<div className="candidate-prepared-message">
<span>Prepared message</span>
<blockquote>{preparation.message}</blockquote>
</div>
<details className="candidate-analysis">
<summary>Show image analysis</summary>
<pre>{preparation.imageAnalysis}</pre>
</details>
</section>
);
}
function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) {
const waitingUntil = candidate.availableAt > now;
const isKnownStatus = KNOWN_STATUSES.has(candidate.status);
@@ -61,11 +108,13 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
<div className="candidate-meta">
<RedditUsernameLink username={candidate.author} />
<span>{candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'}</span>
{candidate.physiqueScore !== null ? <span>Physique {candidate.physiqueScore}/10</span> : null}
<span>{candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'}</span>
{waitingUntil ? <span title={formatExactTime(candidate.availableAt)}>Retry {formatWaitTime(candidate.availableAt, now)}</span> : null}
</div>
{candidate.note ? <p className="candidate-note"><strong>Latest note</strong>{candidate.note}</p> : null}
{candidate.owner ? <AccountOwnerBadge owner={candidate.owner} /> : null}
<PreparedCandidateDetails candidate={candidate} />
</article>
</li>
);
@@ -43,6 +43,31 @@
.candidate-note { display: grid; gap: .18rem; margin: 0; padding: .55rem .7rem; color: #d9c99e; background: #251f13; border-left: 2px solid var(--warning); border-radius: .35rem; font-size: .78rem; overflow-wrap: anywhere; }
.candidate-note strong { color: var(--warning-text); font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
.outreach-candidate .account-owner-badge { margin-top: -.1rem; }
.candidate-preparation { display: grid; gap: .8rem; padding-top: .8rem; border-top: 1px solid var(--border); }
.candidate-preparation-grid { display: grid; gap: .8rem; }
.candidate-preparation .origin-post-card { grid-template-columns: 1fr; align-items: stretch; gap: .55rem; margin: 0; padding: .6rem; background: #111915; border: 1px solid var(--border); border-radius: .6rem; }
.candidate-preparation .origin-post-image { width: 100%; height: 12rem; }
.candidate-preparation-copy { display: grid; gap: .7rem; min-width: 0; }
.candidate-score-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .55rem; }
.candidate-score-grid > div { display: grid; gap: .08rem; min-width: 0; padding: .6rem .7rem; background: #111c17; border: 1px solid var(--border); border-radius: .55rem; }
.candidate-score-grid span, .candidate-prepared-message > span { color: var(--muted); font-size: .66rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
.candidate-score-grid strong { color: #eef6f1; font-size: 1rem; text-transform: capitalize; overflow-wrap: anywhere; }
.candidate-score-grid small { color: var(--muted); font-size: .67rem; }
.candidate-preparation-metadata { display: flex; flex-wrap: wrap; gap: .4rem .8rem; color: var(--muted); font-size: .72rem; overflow-wrap: anywhere; }
.candidate-preparation-metadata strong { color: #dce8e2; }
.candidate-prepared-message { display: grid; gap: .35rem; }
.candidate-prepared-message blockquote { margin: 0; padding: .7rem .8rem; color: #eef6f1; background: rgba(117, 214, 157, .07); border-left: 3px solid var(--accent); border-radius: .35rem; white-space: pre-wrap; overflow-wrap: anywhere; }
.candidate-analysis { padding: .55rem .7rem; background: #111915; border: 1px solid var(--border); border-radius: .5rem; }
.candidate-analysis summary { width: fit-content; color: var(--accent); cursor: pointer; font-size: .75rem; font-weight: 750; }
.candidate-analysis pre { margin: .65rem 0 0; color: #dce8e2; font: inherit; font-size: .76rem; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
@media (min-width: 48rem) {
.candidate-preparation-grid:has(.origin-post-card) { grid-template-columns: minmax(15rem, .8fr) minmax(0, 1.2fr); align-items: stretch; }
}
@media (max-width: 34rem) {
.candidate-score-grid { grid-template-columns: 1fr; }
}
@media (min-width: 64rem) {
.outreach-stages { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; }
@@ -29,6 +29,9 @@ export interface SettingsDraft {
prioritizeRecentPosts: boolean;
recentWindowMinutes: string;
maxReadyCandidates: string;
physiqueTargetingEnabled: boolean;
physiqueMinScore: string;
physiqueMaxScore: string;
subreddits: SubredditDraft[];
advancedConfigJson: string;
}
@@ -57,12 +60,22 @@ const ValidatedSettingsSchema = z.strictObject({
prioritizeRecentPosts: z.boolean(),
recentWindowMinutes: z.number().finite().positive('Recent window must be greater than 0 minutes.'),
maxReadyCandidates: z.number().int().min(1, 'Ready-post limit must be at least 1.').max(500, 'Ready-post limit cannot exceed 500.'),
physiqueTargetingEnabled: z.boolean(),
physiqueMinScore: z.number().int().min(1, 'Minimum physique score must be between 1 and 10.').max(10, 'Minimum physique score must be between 1 and 10.'),
physiqueMaxScore: z.number().int().min(1, 'Maximum physique score must be between 1 and 10.').max(10, 'Maximum physique score must be between 1 and 10.'),
subreddits: z.array(EditableSubredditSchema).min(1, 'At least one subreddit is required.'),
}).superRefine((value, context) => {
const names = value.subreddits.map((subreddit) => subreddit.name.trim().toLowerCase());
if (new Set(names).size !== names.length) {
context.addIssue({ code: 'custom', path: ['subreddits'], message: 'Subreddit names must be unique.' });
}
if (value.physiqueMinScore > value.physiqueMaxScore) {
context.addIssue({
code: 'custom',
path: ['physiqueMinScore'],
message: 'Minimum physique score cannot exceed maximum physique score.',
});
}
});
let nextRowId = 0;
@@ -81,6 +94,9 @@ export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
maxReadyCandidates: String(config.reddit.maxReadyCandidates),
physiqueTargetingEnabled: config.reddit.physiqueTargeting.enabled,
physiqueMinScore: String(config.reddit.physiqueTargeting.minScore),
physiqueMaxScore: String(config.reddit.physiqueTargeting.maxScore),
subreddits: config.subreddits.map((subreddit) => ({
rowId: createSubredditRowId(),
name: subreddit.name,
@@ -104,6 +120,8 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
if (draft.jitterSeconds.trim() === '') requiredErrors.jitterSeconds = 'Jitter is required.';
if (draft.recentWindowMinutes.trim() === '') requiredErrors.recentWindowMinutes = 'Recent window is required.';
if (draft.maxReadyCandidates.trim() === '') requiredErrors.maxReadyCandidates = 'Ready-post limit is required.';
if (draft.physiqueMinScore.trim() === '') requiredErrors.physiqueMinScore = 'Minimum physique score is required.';
if (draft.physiqueMaxScore.trim() === '') requiredErrors.physiqueMaxScore = 'Maximum physique score is required.';
draft.subreddits.forEach((subreddit, index) => {
if (subreddit.name.trim() === '') requiredErrors[`subreddits.${index}.name`] = 'Subreddit name is required.';
if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) {
@@ -140,6 +158,9 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
prioritizeRecentPosts: draft.prioritizeRecentPosts,
recentWindowMinutes: Number(draft.recentWindowMinutes),
maxReadyCandidates: Number(draft.maxReadyCandidates),
physiqueTargetingEnabled: draft.physiqueTargetingEnabled,
physiqueMinScore: Number(draft.physiqueMinScore),
physiqueMaxScore: Number(draft.physiqueMaxScore),
subreddits: draft.subreddits.map((subreddit) => ({
name: subreddit.name.trim(),
enabled: subreddit.enabled,
@@ -185,6 +206,11 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
prioritizeRecentPosts: parsed.data.prioritizeRecentPosts,
recentWindowMinutes: parsed.data.recentWindowMinutes,
maxReadyCandidates: parsed.data.maxReadyCandidates,
physiqueTargeting: {
enabled: parsed.data.physiqueTargetingEnabled,
minScore: parsed.data.physiqueMinScore,
maxScore: parsed.data.physiqueMaxScore,
},
},
subreddits: parsed.data.subreddits,
...(advanced ? { advanced } : {}),
@@ -217,7 +243,7 @@ export function isDraftDirty(draft: SettingsDraft, persisted: SettingsDraft): bo
*/
export function changedDraftFields(previous: SettingsDraft, next: SettingsDraft): string[] {
const changed: string[] = [];
const topLevelKeys = ['minDelayMinutes', 'maxMessagesPerHour', 'jitterSeconds', 'dryRun', 'prioritizeRecentPosts', 'recentWindowMinutes', 'maxReadyCandidates', 'advancedConfigJson'] as const;
const topLevelKeys = ['minDelayMinutes', 'maxMessagesPerHour', 'jitterSeconds', 'dryRun', 'prioritizeRecentPosts', 'recentWindowMinutes', 'maxReadyCandidates', 'physiqueTargetingEnabled', 'physiqueMinScore', 'physiqueMaxScore', 'advancedConfigJson'] as const;
for (const key of topLevelKeys) {
if (previous[key] !== next[key]) changed.push(key);
}
@@ -12,11 +12,12 @@ interface NumberFieldProps {
label: string;
name: string;
min?: number;
max?: number;
value: string;
onChange(value: string): void;
}
function NumberField({ error, label, name, min, value, onChange }: NumberFieldProps) {
function NumberField({ error, label, name, min, max, value, onChange }: NumberFieldProps) {
const errorId = `${name}-error`;
return (
<label className="settings-field">
@@ -26,6 +27,7 @@ function NumberField({ error, label, name, min, value, onChange }: NumberFieldPr
aria-invalid={error ? 'true' : undefined}
aria-label={label}
min={min}
max={max}
name={name}
onChange={(event) => onChange(event.target.value)}
step="1"
@@ -66,6 +68,41 @@ export function MonitoringSection({ draft, errors, onChange }: SectionProps) {
onChange={(maxReadyCandidates) => onChange({ ...draft, maxReadyCandidates })}
value={draft.maxReadyCandidates}
/>
<div className="advanced-settings">
<div>
<h3>Physique targeting</h3>
<p>Apply a subjective 1–10 visible-physique range to image posts. Images without enough visible body evidence are screened out while this is enabled.</p>
</div>
<label className="toggle-field">
<input
aria-label="Enable physique targeting"
checked={draft.physiqueTargetingEnabled}
onChange={(event) => onChange({ ...draft, physiqueTargetingEnabled: event.target.checked })}
type="checkbox"
/>
<span><strong>Enable physique targeting</strong><small>Chat posts are unaffected.</small></span>
</label>
<div className="settings-grid">
<NumberField
error={errors.physiqueMinScore}
label="Minimum physique score"
min={1}
max={10}
name="minimum-physique-score"
onChange={(physiqueMinScore) => onChange({ ...draft, physiqueMinScore })}
value={draft.physiqueMinScore}
/>
<NumberField
error={errors.physiqueMaxScore}
label="Maximum physique score"
min={1}
max={10}
name="maximum-physique-score"
onChange={(physiqueMaxScore) => onChange({ ...draft, physiqueMaxScore })}
value={draft.physiqueMaxScore}
/>
</div>
</div>
</section>
);
}