From c43e534cf107157cf737e3349605e39acbcec38f Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Tue, 11 Aug 2026 09:07:31 +0100 Subject: [PATCH] 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), Targeting, 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. Physique targeting moves into its own Targeting card. It sends only reddit.physiqueTargeting, which the config route applies independently, so it cannot clobber the pacing card's fields. Turning screening on now warns that it will reduce how many posts qualify. 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. Co-Authored-By: Claude Opus 5 --- apps/dashboard/src/app/app-shell.tsx | 31 +- apps/dashboard/src/app/navigation.ts | 30 +- apps/dashboard/src/app/notifications.tsx | 2 +- apps/dashboard/src/app/router.tsx | 4 + apps/dashboard/src/components/form-fields.tsx | 225 +++++ .../src/components/unsaved-changes.tsx | 94 ++ .../src/features/accounts/accounts-page.tsx | 213 ++++ .../src/features/accounts/accounts.css | 10 + .../conversations/start-conversation-form.tsx | 2 +- .../src/features/settings/access-panel.tsx | 112 --- .../src/features/settings/advanced-card.tsx | 85 ++ .../src/features/settings/pacing-card.tsx | 120 +++ .../settings/push-notifications-panel.tsx | 184 ++-- .../src/features/settings/safety-card.tsx | 46 + .../src/features/settings/settings-card.tsx | 97 ++ .../src/features/settings/settings-editor.tsx | 234 ----- .../src/features/settings/settings-model.ts | 406 ++++---- .../src/features/settings/settings-page.tsx | 102 +- .../src/features/settings/settings-route.tsx | 33 +- .../features/settings/settings-sections.tsx | 337 ------- .../src/features/settings/settings.css | 83 +- .../src/features/settings/targeting-card.tsx | 70 ++ .../src/features/settings/use-config-card.ts | 155 +++ .../subreddits/subreddit-bulk-bar.tsx | 114 +++ .../features/subreddits/subreddit-editor.tsx | 266 +++++ .../features/subreddits/subreddit-model.ts | 248 +++++ .../features/subreddits/subreddits-page.tsx | 499 +++++++++ .../src/features/subreddits/subreddits.css | 62 ++ apps/dashboard/src/styles.css | 98 ++ packages/api-contracts/src/config.ts | 1 + tests/dashboard/accounts.test.tsx | 184 ++++ tests/dashboard/conversations.test.tsx | 2 +- .../push-notifications-settings.test.tsx | 8 +- tests/dashboard/settings-model.test.ts | 346 +++++++ tests/dashboard/settings.test.tsx | 943 ++++++++---------- tests/dashboard/subreddits.test.tsx | 600 +++++++++++ 36 files changed, 4414 insertions(+), 1632 deletions(-) create mode 100644 apps/dashboard/src/components/form-fields.tsx create mode 100644 apps/dashboard/src/components/unsaved-changes.tsx create mode 100644 apps/dashboard/src/features/accounts/accounts-page.tsx create mode 100644 apps/dashboard/src/features/accounts/accounts.css delete mode 100644 apps/dashboard/src/features/settings/access-panel.tsx create mode 100644 apps/dashboard/src/features/settings/advanced-card.tsx create mode 100644 apps/dashboard/src/features/settings/pacing-card.tsx create mode 100644 apps/dashboard/src/features/settings/safety-card.tsx create mode 100644 apps/dashboard/src/features/settings/settings-card.tsx delete mode 100644 apps/dashboard/src/features/settings/settings-editor.tsx delete mode 100644 apps/dashboard/src/features/settings/settings-sections.tsx create mode 100644 apps/dashboard/src/features/settings/targeting-card.tsx create mode 100644 apps/dashboard/src/features/settings/use-config-card.ts create mode 100644 apps/dashboard/src/features/subreddits/subreddit-bulk-bar.tsx create mode 100644 apps/dashboard/src/features/subreddits/subreddit-editor.tsx create mode 100644 apps/dashboard/src/features/subreddits/subreddit-model.ts create mode 100644 apps/dashboard/src/features/subreddits/subreddits-page.tsx create mode 100644 apps/dashboard/src/features/subreddits/subreddits.css create mode 100644 tests/dashboard/accounts.test.tsx create mode 100644 tests/dashboard/settings-model.test.ts create mode 100644 tests/dashboard/subreddits.test.tsx diff --git a/apps/dashboard/src/app/app-shell.tsx b/apps/dashboard/src/app/app-shell.tsx index 0399ef6..3717307 100644 --- a/apps/dashboard/src/app/app-shell.tsx +++ b/apps/dashboard/src/app/app-shell.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { NavLink, Outlet } from 'react-router-dom'; -import { navigationItems } from './navigation.js'; +import { navigationGroups, navigationItemsByGroup } from './navigation.js'; import { ShellStatusPanel } from './shell-status-panel.js'; import { NotificationBell, NotificationProvider, NotificationViewport } from './notifications.js'; @@ -20,18 +20,23 @@ export function AppShell() { diff --git a/apps/dashboard/src/app/navigation.ts b/apps/dashboard/src/app/navigation.ts index ca7400a..a426e8d 100644 --- a/apps/dashboard/src/app/navigation.ts +++ b/apps/dashboard/src/app/navigation.ts @@ -2,16 +2,28 @@ export interface NavigationItem { label: string; path: string; description: string; + group: NavigationGroup; } +export type NavigationGroup = 'Monitor' | 'Content' | 'Analyse' | 'Configure'; + +/** Render order for the sidebar's group headings. */ +export const navigationGroups: readonly NavigationGroup[] = ['Monitor', 'Content', 'Analyse', 'Configure']; + export const navigationItems: readonly NavigationItem[] = [ - { label: 'Overview', path: '/', description: 'System health and current activity' }, - { label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks' }, - { label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery' }, - { label: 'Conversations', path: '/conversations', description: 'Inbox and message history' }, - { label: 'Media', path: '/media', description: 'Private media and sync controls' }, - { label: 'Exchange library', path: '/media-library', description: 'Images the bot may send when asked' }, - { label: 'Performance', path: '/performance', description: 'Response and strategy trends' }, - { label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics' }, - { label: 'Settings', path: '/settings', description: 'Runtime and access configuration' }, + { label: 'Overview', path: '/', description: 'System health and current activity', group: 'Monitor' }, + { label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks', group: 'Monitor' }, + { label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery', group: 'Monitor' }, + { label: 'Conversations', path: '/conversations', description: 'Inbox and message history', group: 'Monitor' }, + { label: 'Media', path: '/media', description: 'Private media and sync controls', group: 'Content' }, + { label: 'Exchange library', path: '/media-library', description: 'Images the bot may send when asked', group: 'Content' }, + { label: 'Performance', path: '/performance', description: 'Response and strategy trends', group: 'Analyse' }, + { label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics', group: 'Analyse' }, + { label: 'Subreddits', path: '/subreddits', description: 'Where the bot posts and how it talks', group: 'Configure' }, + { label: 'Accounts', path: '/accounts', description: 'Reddit sessions and access', group: 'Configure' }, + { label: 'Settings', path: '/settings', description: 'Pacing, safety, and notifications', group: 'Configure' }, ]; + +export function navigationItemsByGroup(group: NavigationGroup): readonly NavigationItem[] { + return navigationItems.filter((item) => item.group === group); +} diff --git a/apps/dashboard/src/app/notifications.tsx b/apps/dashboard/src/app/notifications.tsx index 35c0be8..3d5860d 100644 --- a/apps/dashboard/src/app/notifications.tsx +++ b/apps/dashboard/src/app/notifications.tsx @@ -158,7 +158,7 @@ export function notificationForEvent(event: DashboardEvent): NotificationDraft | ? `Signed in as u/${event.payload.username}.` : `Session status changed to ${event.payload.status}.`), tone: event.payload.status === 'authenticated' ? 'success' : 'error', - href: '/settings', + href: '/accounts', }; case 'conversation.updated': case 'sync.started': diff --git a/apps/dashboard/src/app/router.tsx b/apps/dashboard/src/app/router.tsx index 8d32f7d..e754f08 100644 --- a/apps/dashboard/src/app/router.tsx +++ b/apps/dashboard/src/app/router.tsx @@ -10,6 +10,8 @@ import { RouteErrorPage } from './route-error-page.js'; import { AttentionPage } from '../features/attention/attention-page.js'; import { OutreachPage } from '../features/outreach/outreach-page.js'; import { MediaLibraryPage } from '../features/media-library/media-library-page.js'; +import { SubredditsPage } from '../features/subreddits/subreddits-page.js'; +import { AccountsPage } from '../features/accounts/accounts-page.js'; export function createDashboardRoutes(): RouteObject[] { return [ @@ -27,6 +29,8 @@ export function createDashboardRoutes(): RouteObject[] { { path: 'media-library', element: }, { path: 'performance', element: }, { path: 'operations', element: }, + { path: 'subreddits', element: }, + { path: 'accounts', element: }, { path: 'settings', element: }, ], }, diff --git a/apps/dashboard/src/components/form-fields.tsx b/apps/dashboard/src/components/form-fields.tsx new file mode 100644 index 0000000..70ff0d9 --- /dev/null +++ b/apps/dashboard/src/components/form-fields.tsx @@ -0,0 +1,225 @@ +import type { ReactNode } from 'react'; + +interface FieldShellProps { + error?: string; + /** Guidance shown when there is no error to show in its place. */ + hint?: ReactNode; + label: string; + name: string; +} + +function fieldIds(name: string) { + return { errorId: `${name}-error`, hintId: `${name}-hint` }; +} + +function FieldMessage({ error, hint, name }: FieldShellProps) { + const { errorId, hintId } = fieldIds(name); + if (error) return {error}; + if (hint) return {hint}; + return null; +} + +function describedBy({ error, hint, name }: FieldShellProps): string | undefined { + const { errorId, hintId } = fieldIds(name); + if (error) return errorId; + return hint ? hintId : undefined; +} + +export interface NumberFieldProps extends FieldShellProps { + max?: number; + min?: number; + step?: number | 'any'; + /** Rendered inside the input as a trailing unit, e.g. "min" or "/hr". */ + suffix?: string; + value: string; + onChange(value: string): void; +} + +export function NumberField({ error, hint, label, max, min, name, onChange, step = 1, suffix, value }: NumberFieldProps) { + const shell = { error, hint, label, name }; + return ( + + ); +} + +export interface TextFieldProps extends FieldShellProps { + placeholder?: string; + /** Rendered as a non-editable prefix inside the input, e.g. "r/". */ + prefix?: string; + type?: 'text' | 'url'; + value: string; + onChange(value: string): void; +} + +export function TextField({ error, hint, label, name, onChange, placeholder, prefix, type = 'text', value }: TextFieldProps) { + const shell = { error, hint, label, name }; + return ( + + ); +} + +export interface SelectFieldProps extends FieldShellProps { + options: ReadonlyArray<{ value: Value; label: string }>; + value: Value; + onChange(value: Value): void; +} + +export function SelectField({ error, hint, label, name, onChange, options, value }: SelectFieldProps) { + const shell = { error, hint, label, name }; + return ( + + ); +} + +export interface TextAreaFieldProps extends FieldShellProps { + monospace?: boolean; + placeholder?: string; + rows?: number; + value: string; + onChange(value: string): void; +} + +export function TextAreaField({ error, hint, label, monospace, name, onChange, placeholder, rows = 3, value }: TextAreaFieldProps) { + const shell = { error, hint, label, name }; + return ( +