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 runNow = useRunBrowsingSimulationMutation(account.accountId);
const { settings, stats, todayTarget, recentDecisions } = account.browsing; const { settings, stats, todayTarget, recentDecisions } = account.browsing;
const selected = recentDecisions.filter((decision) => decision.decision === 'simulated-upvote'); 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 [gapMinutes, setGapMinutes] = useState(String(settings.minimumGapMs / 60_000));
const [selectionsPerSession, setSelectionsPerSession] = useState(String(settings.selectionsPerSession)); 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(() => setGapMinutes(String(settings.minimumGapMs / 60_000)), [settings.minimumGapMs]);
useEffect(() => setSelectionsPerSession(String(settings.selectionsPerSession)), [settings.selectionsPerSession]); 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 ( return (
<section aria-labelledby="browsing-simulation-heading" className="panel settings-card"> <section aria-labelledby="browsing-simulation-heading" className="panel settings-card">
<div className="settings-card-heading"> <div className="settings-card-heading">
@@ -212,6 +227,40 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
label="Submit selected upvotes" label="Submit selected upvotes"
onChange={(autoVoteEnabled) => update.mutate({ autoVoteEnabled })} 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"> <div className="growth-pacing">
<NumberField <NumberField
hint={`Shortest gap between sessions. The system adds a random 0–${minutes(settings.jitterMs)} on top.`} 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. 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 ## 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. 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. 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 () => { it('shows an empty state when there are no accounts', async () => {
overviewPayload = []; overviewPayload = [];
renderGrowth(); renderGrowth();