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>
121 lines
4.1 KiB
TypeScript
121 lines
4.1 KiB
TypeScript
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>
|
||
);
|
||
}
|