Repository source: docs/plans/archive/forte_users_three_pane_step3_development_plan.md
Forte Users Three-Pane Layout — Step 3: Development Plan
Stage 1
Goal: Serve a single user's detail-pane HTML fragment on demand, mirroring the existing commit-detail fragment endpoint.
Dependencies: none
Expected changes:
New partial
templates/partials/paned_user_detail_pane.php(placeholder + populated states) built from datarenderForteUsername()already assembles: approved profiles, thread/post counts, threads, posts.New route case
/api/forte_user_detailinApplication.php's dispatcher, alongside/api/forte_commit_detail.New private method (conceptual)
handleForteUserDetail(array $query): void, reusingfetchProfilesByUsernameToken(),countVisibleAuthoredRows(),fetchVisibleAuthoredThreads(),fetchVisibleAuthoredPosts(); responds viarenderer()->renderFragment()+sendJson(['status' => 'ok', 'html' => ...]), JSON 404 when the token isn't found.
Verification approach:
curl "/api/forte_user_detail?username_token=<known token>"locally; confirm 200 JSON with the expected fragment HTML, and 404 JSON for an unknown token.Risks or open questions:
Directory only lists approved users, so an unresolvable token shouldn't occur in normal use — guard with 404 regardless.
Canonical components/API contracts touched: extends the existing
/api/forte_commit_detailfragment pattern (renderFragment+sendJson); reusesrenderForteUsername()'s existing data-fetch methods instead of new queries.
Stage 2
Goal: Add an alphabetical filter pane so the Users list can be narrowed, mirroring Board's tag folder tree.
Dependencies: none (sequenced after Stage 1 for review clarity)
Expected changes:
New private method (conceptual)
buildUserDirectoryLetterGroups(array $users): arrayreturning[{letter, count}], computed from the existingfetchApprovedUserDirectoryUsers()result by first character ofusername_token— no new SQL.New partial
templates/partials/paned_users_filter_list.php, modeled onpaned_folder_tree.php: "All Users" entry plus one entry per letter with counts.
Verification approach: render
renderForteUserDirectory()via a local PHP include/CLI check and confirm letter-group counts sum to the total approved user count.Risks or open questions: none
Canonical components/API contracts touched:
paned_folder_tree.php's markup/CSS conventions (paned-folder-item,paned-folder-count) reused, not forked.
Stage 3
Goal: Render the approved user directory as selectable rows instead of a flat
<p>list.Dependencies: none (sequenced after Stage 2 for review clarity)
Expected changes:
New partial
templates/partials/paned_user_list.php, modeled onpaned_board_thread_list.php: onepaned-list-rowper user withdata-paned-user-token/data-paned-user-letterattributes and username/thread-count/post-count columns; rows outside the selected letter arehidden.
Verification approach: render
renderForteUserDirectory()locally and inspect generated row markup/attributes for a few known users.Risks or open questions: none
Canonical components/API contracts touched:
paned_board_thread_list.php's row/selection markup conventions (paned-list-row,role="option",tabindex) reused, not forked.
Stage 4
Goal: Replace
forte_users.php's flat body with the 3-pane layout and wire request params through.Dependencies: Stages 1-3
Expected changes:
Rewrite
templates/pages/forte_users.phpto thepaned-window> toolbar >paned-board-layout(filter pane +paned-panes-stackholding the list pane and a detail-pane placeholder) structureforte_board.phpalready uses.Extend
renderForteUserDirectory()(conceptual signature:renderForteUserDirectory(string $requestedLetter = '', string $requestedSelected = '')) to passletterGroups,selectedLetter,selectedUserTokento the template; detail pane starts in the existing "nothing selected" placeholder state (fetched client-side in Stage 5, no server-side detail prefetch).Update the
/forte/users/route to pass$query['letter']/$query['selected']through, mirroring the/forte/?route.
Verification approach: load
/forte/users/,/forte/users/?letter=a,/forte/users/?selected=<token>with JS disabled/via curl; confirm correct server-rendered filtering/highlighting with no errors.Risks or open questions: none
Canonical components/API contracts touched:
paned_toolbar.php(unchanged,activeView: 'users'),paned-board-layout/paned-panes-stackCSS classes reused, not forked.
Stage 5
Goal: Make filtering/selecting interactive without full-page reloads.
Dependencies: Stage 4
Expected changes:
New
public/assets/paned_users_reader.js, mirroring the URL-state pattern inpaned_board_reader.js/paned_activity_reader.js: letter click toggles rowhiddenand updates?letter=viahistory.pushState; row click updates.paned-list-row--selected, updates?selected=, and fetches/api/forte_user_detail?username_token=to swap the returned HTML into the detail pane (same fetch/inject pattern as the existing commit-detail handler).Register
/assets/paned_users_reader.jsas the scripts argument inrenderForteUserDirectory()'srenderStandalonePage()call.
Verification approach: manual browser test on
/forte/users/— click letters and rows, confirm the detail pane updates without a reload, the URL reflects state, and back/forward restores prior selection (same check already used for Board/Activity).Risks or open questions:
Match the detail pane's loading/error states to the existing profile-summary dialog's loading/error pattern for consistency.
Canonical components/API contracts touched:
/api/forte_user_detail(Stage 1), URL-state fetch/swap pattern frompaned_activity_reader.js.
Stage 6
Goal: Confirm Board/Activity are unaffected and the new Users pane is visually consistent.
Dependencies: Stages 1-5
Expected changes:
Add
paned-user-*-scoped CSS only if user-row columns need widths different from thread rows; no edits to existing Board/Activity selectors inforte.css.
Verification approach: manual walkthrough of
/forte,/forte/activity/,/forte/users/at desktop and narrow widths, light and dark; confirm Board/Activity are pixel-identical to pre-change behavior and Users matches their look/feel.Risks or open questions: none
Canonical components/API contracts touched:
forte.cssshared layout classes, extended only if necessary, never forked.
Addendum: Semantic Filter Categories (post-Step-4 revision)
Supersedes the alphabetical filter (Stages 2/4/5 above) per the Step 2 addendum. New Stages 7-13 below.
Stage 7
Goal: Compute the data the semantic categories need: a per-
username_tokenlast-activity timestamp, and per-user category flags (new/established/no_threads/recently_active).Dependencies: none
Expected changes:
New private method (conceptual)
fetchUserDirectoryLastActivityByToken(): array—MAX(posts.created_at)joined throughprofiles.identity_id, grouped up tousername_token(a user with multiple approved identities takes the max across all of them). New query, no schema change.New private method (conceptual)
buildUserDirectoryCategoryFlags(array $users, array $lastActivityByToken): array— per user, computesnew(NOT (thread_count >= 1ANDpost_count - thread_count >= 1)),established(the inverse),no_threads(thread_count === 0),recently_active(last activity within 7 days of now).Removes
buildUserDirectoryLetterGroups()(superseded; no longer called after Stage 11).
Verification approach: reflection-based script (as used in original Stage 2) computing flags/timestamps against the real local dataset; spot-check 2-3 known users' flags by hand against their actual thread/post counts and most recent post date.
Risks or open questions:
"Established" requires a reply specifically, not just 2+ posts — confirm the
post_count - thread_count >= 1reading of "1 post + 1 comment" is right before Stage 9 renders it as a label.
Canonical components/API contracts touched: none new; reuses
fetchApprovedUserDirectoryUsers()'s existingthread_count/post_countcolumns.
Stage 8
Goal: Replace the alphabetical filter pane with a semantic-category one, plus a separate "Not Approved" entry.
Dependencies: Stage 7
Expected changes:
Rewrite
templates/partials/paned_users_filter_list.phpfrom per-letter entries to fixed category entries (All Users, New, Established, No Threads, Recently Active, Not Approved), modeled onpaned_activity_filter_list.php's fixed-view structure rather thanpaned_folder_tree.php's per-tag structure.Counts per category come from Stage 7's flags (approved categories) plus
count(fetchNeverApprovedPendingUserDirectoryUsers())for Not Approved (Stage 9 correction: rolled up and excludes tokens that have ever been approved, not the rawfetchPendingUserDirectoryProfiles()).
Verification approach: render the partial via
renderFragment()with real counts; confirm each category count against a manual tally from the Stage 7 script's output.Risks or open questions: none
Canonical components/API contracts touched:
paned_activity_filter_list.php's fixed-category markup/CSS conventions, reused not forked.
Stage 9
Goal: Replace per-row letter attributes with per-row category-membership flags, and list Not Approved users alongside (not instead of) approved ones.
Dependencies: Stage 7
Expected changes:
Rewrite
templates/partials/paned_user_list.php: dropdata-paned-user-row-letter; adddata-paned-user-view-new,-established,-no-threads,-recently-activeflags (mirroring Activity'sdata-paned-activity-view-*convention), each"1"/absent per Stage 7's flags. A row can carry multiple flags at once.Append pending rows from
fetchNeverApprovedPendingUserDirectoryUsers()(Stage 9 correction, see summary) after the approved rows, each carryingdata-paned-user-category-not-approved="1"and no other flags, visually distinguished (e.g. a muted/pending style) and hidden unless the Not Approved category is selected."All Users" continues to mean approved users only; pending rows are never visible under "All Users".
Verification approach: render with real data; confirm approved-row flag combinations against Stage 7's script output, and confirm pending rows appear only when Not Approved is selected.
Risks or open questions: none
Canonical components/API contracts touched:
paned-list-rowmarkup conventions reused; flag-attribute convention borrowed frompaned_activity_item_row.php.
Stage 10
Goal: Give a Not Approved row a meaningful detail-pane view (pending profiles have no visible threads/posts the way approved users do).
Dependencies: Stage 9
Expected changes:
Extend
handleForteUserDetail(): when no approved profile matches the token, fall back tofetchNeverApprovedPendingUserDirectoryUsers()(Stage 9 correction); if a pending profile matches, render a new partialpaned_user_pending_detail_pane.php(username, pending profile count, thread/post counts so far) instead of 404. Still 404 when neither approved nor pending matches.Read-only: no approve action embedded here (that stays on the existing
/users/pending/admin page) — keeps this endpoint from taking on moderation-action scope.
Verification approach:
curl /api/forte_user_detail?username_token=<known pending token>→ 200 with pending-shaped HTML; confirm an approved token still returns the Stage 1 fragment unchanged.Risks or open questions: none
Canonical components/API contracts touched: extends the Stage 1
/api/forte_user_detailcontract rather than forking a second endpoint.
Stage 11
Goal: Wire the reworked partials into the page template and route, replacing letter-based params with category-based ones.
Dependencies: Stages 7-10
Expected changes:
Rewrite
renderForteUserDirectory(): droprequestedLetter; all approved+pending rows and all category flags/counts render server-side up front (like Activity's views — switching categories is purely client-side, no new server round trip per category).Update the
/forte/users/route's query param fromlettertoview(mirroring Activity's?view=).Update
templates/pages/forte_users.phpto pass the new category/pending data instead ofletterGroups.
Verification approach:
curl /forte/users/,/forte/users/?view=established,/forte/users/?view=not-approved,/forte/users/?selected=<token>&view=recently-active; confirm correct server-rendered highlighting per the FDP's no-JS-baseline convention.Risks or open questions: none
Canonical components/API contracts touched:
?view=param convention reused from Activity's route.
Stage 12
Goal: Rework the client-side controller for flag-based filtering instead of letter-based.
Dependencies: Stage 11
Expected changes:
Update
public/assets/paned_users_reader.js: replace single-letter visibility toggling with flag-attribute checks (row.getAttribute("data-paned-user-view-" + category) === "1"), mirroringpaned_activity_reader.js'sselectFilter(); keep the existing URL-state/detail-fetch logic from Stage 5, updating theletterparam name toview.
Verification approach: Playwright browser session against the local dev server — click each category, confirm the right rows show/hide (including Not Approved rows only appearing under that category), confirm detail-pane fetch still works for both an approved and a pending row selection, confirm back/forward URL restoration.
Risks or open questions: none
Canonical components/API contracts touched: none new.
Stage 13
Goal: Confirm Board/Activity remain unaffected and remove now-dead alphabetical code.
Dependencies: Stages 7-12
Expected changes:
Remove
buildUserDirectoryLetterGroups()and any now-unreferenced letter-specific markup/CSS (thedata-paned-users-list-panecolumn-width CSS from Stage 6 stays — it's about column proportions, not letters).
Verification approach: full curl + Playwright sweep of
/forte,/forte/activity/,/forte/users/(all categories) with screenshots, same as original Stage 6.Risks or open questions: none
Canonical components/API contracts touched: none new.
Addendum: Fix + Sortable Columns (post-Step-13 follow-ups)
Two small follow-up requests after Stage 13, each logged directly in the Step 4 summary rather than as full plan stages (unambiguous, single-file/well-precedented scope):
Stage 14 — the detail-pane content used an unstyled custom class instead of the shared
.bodyclass, leaving text flush against the pane edge; fixed to reuse.bodylike Board/Activity.Stage 15 — sortable Username/Threads/Posts columns, mirroring
resolveForteBoardSort()/applyForteBoardSort()/paned_board_reader.js's sort mechanism exactly (server-side initial sort + client-sidedata-paned-sort-*-driven re-order,?sort=&dir=URL state).