Expose browsing daily target range

This commit is contained in:
jjbrunton committed 2026-08-11 22:22:09 +01:00
1 parent e890f74478
commit a71ad56f3c
4 files changed
+83 -2

No files matched your search

@@ -178,12 +178,27 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
const runNow = useRunBrowsingSimulationMutation(account.accountId);
const { settings, stats, todayTarget, recentDecisions } = account.browsing;
const selected = recentDecisions.filter((decision) => decision.decision === 'simulated-upvote');
const [dailyMinimum, setDailyMinimum] = useState(String(settings.dailyTargetMin));
const [dailyMaximum, setDailyMaximum] = useState(String(settings.dailyTargetMax));
const [gapMinutes, setGapMinutes] = useState(String(settings.minimumGapMs / 60_000));
const [selectionsPerSession, setSelectionsPerSession] = useState(String(settings.selectionsPerSession));
useEffect(() => setDailyMinimum(String(settings.dailyTargetMin)), [settings.dailyTargetMin]);
useEffect(() => setDailyMaximum(String(settings.dailyTargetMax)), [settings.dailyTargetMax]);
useEffect(() => setGapMinutes(String(settings.minimumGapMs / 60_000)), [settings.minimumGapMs]);
useEffect(() => setSelectionsPerSession(String(settings.selectionsPerSession)), [settings.selectionsPerSession]);
const parsedDailyMinimum = Number.parseInt(dailyMinimum, 10);
const parsedDailyMaximum = Number.parseInt(dailyMaximum, 10);
const dailyRangeValid = Number.isInteger(parsedDailyMinimum)
&& Number.isInteger(parsedDailyMaximum)
&& parsedDailyMinimum >= 1
&& parsedDailyMaximum <= 12
&& parsedDailyMinimum <= parsedDailyMaximum;
const dailyRangeError = dailyMinimum !== '' && dailyMaximum !== '' && !dailyRangeValid
? 'Use a range from 1 to 12, with the minimum no greater than the maximum.'
: undefined;
return (
<section aria-labelledby="browsing-simulation-heading" className="panel settings-card">
<div className="settings-card-heading">
@@ -212,6 +227,40 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
label="Submit selected upvotes"
onChange={(autoVoteEnabled) => update.mutate({ autoVoteEnabled })}
/>
<div className="growth-pacing">
<NumberField
error={dailyRangeError}
hint="The system chooses one stable target inside this range for each account and local day."
label="Daily minimum"
min={1}
max={12}
name="browsing-daily-minimum"
onChange={setDailyMinimum}
suffix="/day"
value={dailyMinimum}
/>
<NumberField
error={dailyRangeError}
label="Daily maximum"
min={1}
max={12}
name="browsing-daily-maximum"
onChange={setDailyMaximum}
suffix="/day"
value={dailyMaximum}
/>
<button
className="button secondary"
disabled={update.isPending || !dailyRangeValid}
onClick={() => update.mutate({
dailyTargetMin: parsedDailyMinimum,
dailyTargetMax: parsedDailyMaximum,
})}
type="button"
>
Save daily range
</button>
</div>
<div className="growth-pacing">
<NumberField
hint={`Shortest gap between sessions. The system adds a random 0–${minutes(settings.jitterMs)} on top.`}
+1 -1
View File
@@ -105,7 +105,7 @@ A per-community `dailyCap` only distributes the account allowance; caps are not
Community weights feed a new weighted-random pick for each discovered growth thread. The grower queues only one new thread at a time, drains due retries and existing queue work before polling another community, and exposes an account-scoped queue clear that removes only unposted discovered/retryable rows.
Browsing sessions are configured separately per account through `/api/growth/{accountId}/browsing` and reuse the enabled growth-community list and weights. Sessions default off and target 3–6 selections per local day with a 60/40 post/comment split, respect quiet hours, exclude NSFW unless enabled, and have a non-overridable 12/day ceiling. The operator chooses 1–12 selections per session and a minimum gap of at least 45 minutes; the service adds a random delay of up to `jitterMs` on top before persisting the next due time. A scheduled session is capped by the remaining daily target, while a manually requested session bypasses that target but remains capped by the remaining hard daily ceiling. `autoVoteEnabled` is a separate, default-off operator setting; it can submit selected upvotes only in running mode when browser dry-run is off. `nextDueAt` is service-owned durable state and cannot be set through the API.
Browsing sessions are configured separately per account through `/api/growth/{accountId}/browsing` and reuse the enabled growth-community list and weights. Sessions default off with a configurable 3–6 selections-per-local-day range and a 60/40 post/comment split, respect quiet hours, exclude NSFW unless enabled, and have a non-overridable 12/day ceiling. The service derives one stable target inside the configured daily range for each account and local day. The operator also chooses 1–12 selections per session and a minimum gap of at least 45 minutes; the service adds a random delay of up to `jitterMs` on top before persisting the next due time. A scheduled session is capped by the remaining daily target, while a manually requested session bypasses that target but remains capped by the remaining hard daily ceiling. `autoVoteEnabled` is a separate, default-off operator setting; it can submit selected upvotes only in running mode when browser dry-run is off. `nextDueAt` is service-owned durable state and cannot be set through the API.
## Runtime-editable subset
+1 -1
View File
@@ -26,7 +26,7 @@ Navigation is declared in `app/navigation.ts`; route elements are composed in `a
The account-growth page states plainly that comments are AI-written and posted without review, shows the adaptive allowance and trust level rather than a settable rate, and surfaces every posted comment's exact text alongside its score and removal state. A paused account replaces the pause control with a resume control and explains why it stopped, because resuming is the one action that can compound the damage.
Its browsing section separates session scheduling from the default-off automatic-upvote switch, lets the operator set the minimum gap and selections per session, and shows whether each selection was submitted or simulated, today's deterministic target, post/comment split, exclusion counts, durable next session, and recent selections with Reddit permalinks. The displayed minimum is followed by bounded random jitter. Scan mode and browser dry-run remain simulation-only. An enabled, running account can also run one immediate session at the configured size, subject to the hard daily ceiling; it bypasses the schedule and follows the same automatic-upvote setting. Enabling browsing does not enable comment growth or bot processing.
Its browsing section separates session scheduling from the default-off automatic-upvote switch, lets the operator set the daily target range, minimum gap, and selections per session, and shows whether each selection was submitted or simulated, today's deterministic target, post/comment split, exclusion counts, durable next session, and recent selections with Reddit permalinks. The daily range is saved atomically; changing it recalculates today's target, which then remains stable for that range and local day. The displayed minimum gap is followed by bounded random jitter. Scan mode and browser dry-run remain simulation-only. An enabled, running account can also run one immediate session at the configured size, subject to the hard daily ceiling; it bypasses the schedule and follows the same automatic-upvote setting. Enabling browsing does not enable comment growth or bot processing.
The Monitoring settings section owns the optional physique-targeting toggle and inclusive 1–10 range. It explains that the signal is subjective, applies only to image posts, and screens out unscorable images while enabled.
+32
View File
@@ -287,6 +287,38 @@ describe('GrowthPage', () => {
])));
});
it('updates the browsing daily target range together', async () => {
renderGrowth();
const user = userEvent.setup();
const minimum = await screen.findByLabelText('Daily minimum');
await user.clear(minimum);
await user.type(minimum, '6');
const maximum = screen.getByLabelText('Daily maximum');
await user.clear(maximum);
await user.type(maximum, '9');
await user.click(screen.getByRole('button', { name: 'Save daily range' }));
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
method: 'PATCH', body: { dailyTargetMin: 6, dailyTargetMax: 9 },
})));
});
it('rejects an inverted browsing daily target range in the dashboard', async () => {
renderGrowth();
const user = userEvent.setup();
const minimum = await screen.findByLabelText('Daily minimum');
await user.clear(minimum);
await user.type(minimum, '8');
const maximum = screen.getByLabelText('Daily maximum');
await user.clear(maximum);
await user.type(maximum, '4');
expect(screen.getByRole('button', { name: 'Save daily range' })).toBeDisabled();
expect(screen.getAllByText(/minimum no greater than the maximum/i)).toHaveLength(2);
});
it('shows an empty state when there are no accounts', async () => {
overviewPayload = [];
renderGrowth();