Files
RedditBot/apps/dashboard/src/features/settings/pacing-card.tsx
T
Jamie BruntonandClaude Opus 5 5a0846965a refactor(dashboard): rethink settings, split out subreddits and accounts
The settings page had accumulated five tabs whose names no longer matched
their contents, three different save models, and the whole subreddit
configuration buried under a tab named after a checkbox.

Structure:
- Subreddits becomes its own page: a list showing each community's mode,
  tone, computed share of polling, and live sent/reply stats, with one
  subreddit edited at a time in a drawer.
- Accounts becomes its own page. It is session lifecycle, not config.
- Settings collapses to one scrolling page: Safety, Pacing (Monitoring and
  Rate limits merged), Notifications, Advanced.
- The sidebar is grouped, having gone from 9 to 11 destinations.

Saving:
- Every section owns its own draft, validation, risk gate and save via
  useConfigCard, submitting only the keys it owns. A syntax error in the
  advanced JSON can no longer block a rate-limit change, and enabling an
  autonomous policy is its own deliberate commit.
- UnsavedChangesProvider restores the leave-page guard across cards that no
  longer share a draft.

Subreddit editing:
- Errors are keyed by field name instead of array index, removing the
  defensive invalidation that existed because row indices shifted on add
  and remove. Confirming an autonomous mode can no longer be attributed to
  the wrong row.
- Weight is shown as the share it actually produces, live while editing.
- Bulk actions cover start/pause polling, tone, policy and removal, because
  production runs 30-40 subreddits and per-row editing does not scale.
  Bulk policy changes name every subreddit turning autonomous, and
  normalise the hybrid mode's turn budget, which has no form to catch it.

Also surfaces the effective sending rate, since the hourly limit and the
minimum delay both throttle and only the binding one matters.

Legacy ?section= links redirect or resolve to anchors.

Fixes found while verifying in a browser: a radio-card rule was shrinking
nested number inputs; .share-preview had no styles; the throughput readout
reported a rate from a blank field; and the reconcile logic warned about a
server change whenever a sibling card saved.

Tests: 116 files, 1105 passing (from 113, 1027). Adds subreddit, accounts
and model unit suites covering share maths, bulk normalisation and the
confirm-by-name behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:00:40 +01:00

121 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { NumberField, SwitchField } from '../../components/form-fields.js';
import { SettingsCard } from './settings-card.js';
import {
createPacingDraft,
estimateThroughput,
pacingRisks,
validatePacingDraft,
type PacingDraft,
} from './settings-model.js';
import { useConfigCard } from './use-config-card.js';
function ThroughputReadout({ draft }: { draft: PacingDraft }) {
const estimate = estimateThroughput(draft);
if (!estimate) {
return <p className="throughput-readout is-unknown">Enter valid numbers to see the effective sending rate.</p>;
}
return (
<p aria-live="polite" className="throughput-readout">
<strong>{estimate.effectivePerHour} messages per hour</strong>
<span>
{estimate.spacingLabel}
{estimate.isDelayBound
? ' — the minimum delay is the binding limit, so raising the hourly limit alone changes nothing.'
: ' — the hourly limit is the binding limit.'}
</span>
</p>
);
}
export function PacingCard({ config }: { config: ConfigResponse }) {
const card = useConfigCard<PacingDraft>(config, {
id: 'pacing',
toDraft: createPacingDraft,
validate: validatePacingDraft,
risks: pacingRisks,
});
const { draft, errors } = card;
return (
<SettingsCard
anchor="pacing"
card={card}
description="How fast the bot sends, and how it picks which posts to act on. Both limits apply at once."
eyebrow="Throughput"
saveLabel="Save pacing"
title="Pacing"
>
<fieldset className="field-group">
<legend>Sending pace</legend>
<div className="field-row">
<NumberField
error={errors.minDelayMinutes}
hint="Shortest gap between two messages."
label="Minimum delay"
min={0}
name="minimum-delay"
onChange={(minDelayMinutes) => card.update({ ...draft, minDelayMinutes })}
suffix="min"
value={draft.minDelayMinutes}
/>
<NumberField
error={errors.maxMessagesPerHour}
hint="Hard ceiling per rolling hour."
label="Hourly limit"
min={1}
name="hourly-limit"
onChange={(maxMessagesPerHour) => card.update({ ...draft, maxMessagesPerHour })}
suffix="msg/hr"
value={draft.maxMessagesPerHour}
/>
<NumberField
error={errors.jitterSeconds}
hint="Random padding so timing looks human."
label="Random jitter"
min={0}
name="jitter"
onChange={(jitterSeconds) => card.update({ ...draft, jitterSeconds })}
suffix="sec"
value={draft.jitterSeconds}
/>
</div>
<ThroughputReadout draft={draft} />
</fieldset>
<fieldset className="field-group">
<legend>Post discovery</legend>
<SwitchField
checked={draft.prioritizeRecentPosts}
description="Act on posts inside the recent window before older ones."
label="Prioritise recent posts"
onChange={(prioritizeRecentPosts) => card.update({ ...draft, prioritizeRecentPosts })}
/>
<div className="field-row">
<NumberField
error={errors.recentWindowMinutes}
hint="How new a post must be to count as recent."
label="Recent window"
min={1}
name="recent-window"
onChange={(recentWindowMinutes) => card.update({ ...draft, recentWindowMinutes })}
suffix="min"
value={draft.recentWindowMinutes}
/>
<NumberField
error={errors.maxReadyCandidates}
hint="Queue depth of posts waiting to be actioned (1–500)."
label="Maximum ready posts"
max={500}
min={1}
name="maximum-ready-posts"
onChange={(maxReadyCandidates) => card.update({ ...draft, maxReadyCandidates })}
suffix="posts"
value={draft.maxReadyCandidates}
/>
</div>
</fieldset>
</SettingsCard>
);
}