Repository source: docs/plans/archive/forte_users_three_pane_step4_implementation_summary.md
Forte Users Three-Pane Layout — Step 4: Implementation Summary
Stage 1 - User detail fragment endpoint
Changes:
Added
/api/forte_user_detailroute insrc/ForumRewrite/Application.php's dispatcher (alongside the other/api/forte_*fragment/JSON routes).Added
handleForteUserDetail(array $query): void, reusingfetchProfilesByUsernameToken(),countVisibleAuthoredRows(),fetchVisibleAuthoredThreads(),fetchVisibleAuthoredPosts()(the same datarenderForteUsername()assembles for/forte/user/{token}); responds{status:'ok', html}viarenderer()->renderFragment()on success,{status:'error', ...}with HTTP 404 when no approved profile matches the token.Added
templates/partials/paned_user_detail_pane.php: the populated detail-pane fragment (username, thread/post counts, thread list, post list, link to the full profile page). This stage covers the fragment content only; the page-template placeholder ("no user selected") state is added in Stage 4 alongside the rest of the page layout.
Verification:
php -lon both changed/added PHP files: no syntax errors.Started local dev server (
php -S 127.0.0.1:8099 -t public public/router.php).curl "http://127.0.0.1:8099/api/forte_user_detail?username_token=ilyag"(a real approved user from/forte/users/) → HTTP 200,{"status":"ok","html":"..."}with correctly rendered thread/post rows.curl "http://127.0.0.1:8099/api/forte_user_detail?username_token=definitely-not-a-real-user"→ HTTP 404,{"status":"error","error":"user not found"}.
Notes:
Deviated slightly from the Step 3 wording ("placeholder + populated states" in one partial): kept this partial populated-content-only, since the placeholder is static markup with no data dependency and belongs in the page template built in Stage 4 — simpler than threading an "empty" branch through the fragment endpoint.
Stage 2 - Alphabetical filter pane
Changes:
Added
buildUserDirectoryLetterGroups(array $users): arraytosrc/ForumRewrite/Application.php, bucketing the approved directory by the first (uppercased) character ofusername_token, with a#bucket for any non-letter lead character. Pure function over the existingfetchApprovedUserDirectoryUsers()result — no new SQL.Added
templates/partials/paned_users_filter_list.php, modeled onpaned_folder_tree.php: an "All Users" entry plus one entry per letter, reusing thepaned-folder-tree/paned-folder-item/paned-folder-countCSS classes unchanged.Not yet wired into
renderForteUserDirectory()or the page template — that happens in Stage 4.
Verification:
php -lon both changed/added files: no syntax errors.Scratch script (via
ReflectionClass, since the method isn't wired into a route yet) calledfetchApprovedUserDirectoryUsers()thenbuildUserDirectoryLetterGroups()against the real local dataset: 39 total users, letter-group counts summed to 39.Same script rendered
paned_users_filter_list.phpviarenderFragment()with the real groups andselectedLetter = 'I'; inspected output — "All Users" plus per-letter rows render with correct counts, and the "I" entry carriespaned-folder-item--selected/aria-selected="true".
Notes:
No role/status field exists on
profiles, so alphabetical grouping is the schema-free filter dimension per the Step 1 recommendation.
Stage 3 - Listing pane partial
Changes:
Added
templates/partials/paned_user_list.php, modeled onpaned_board_thread_list.php: onepaned-list-rowper approved user withdata-paned-user-token/data-paned-user-row-letterattributes, columns for username/thread-count/post-count,hiddenon rows outside$selectedLetter, andpaned-list-row--selected/aria-selected/tabindexselection state driven by$selectedUserToken(mirroring the thread list's selection convention). The row's letter is derived inline in the template fromusername_token, the same way Board's thread list derives its tags inline rather than requiring precomputed fields.Not yet wired into
renderForteUserDirectory()or the page template — that happens in Stage 4.
Verification:
php -l: no syntax errors.Rendered the partial via
renderFragment()with the real 39-user dataset,selectedLetter = 'I',selectedUserToken = 'ilyag': 3 rows visible / 36 rows carryhidden(matches Stage 2's letter-group count for "I"), exactly one row carriespaned-list-row--selected, and theilyagrow is present with the expecteddata-paned-user-tokenattribute.
Notes: none
Stage 4 - Page template + route wiring
Changes:
Rewrote
templates/pages/forte_users.phpfrom a flatpaned-standalone-bodylist to thepaned-window>paned-menubar> toolbar >paned-board-layout(filter pane +paned-panes-stackholding the list pane and a static "No user selected" detail-pane placeholder) structureforte_board.phpalready uses; added a status-bar line mirroring Board's ("Showing N of M users (LETTER)").Extended
renderForteUserDirectory(string $requestedLetter = '', string $requestedSelected = '')to computeletterGroupsvia Stage 2'sbuildUserDirectoryLetterGroups()and passselectedLetter/selectedUserToken(uppercased/lowercased respectively) to the template.Updated the
/forte/users/route to pass$query['letter']/$query['selected']through, mirroring the/forte/?route'stag/selectedhandling.Scripts argument to
renderStandalonePage()intentionally left as[]—paned_users_reader.jsis registered in Stage 5 once it exists.
Verification:
php -lon both changed files: no syntax errors.curl /forte/users/→ 200, correct 3-pane HTML, status bar reads "39 users".curl /forte/users/?letter=I→ the "I" filter entry carriespaned-folder-item--selected, status bar reads "Showing 3 of 39 users (I)".curl /forte/users/?selected=ilyag→ theilyagrow carriespaned-list-row--selected.curl /forte/users/?letter=i&selected=ilyag→ 200 (combined params, case-insensitive letter).curl /forte/andcurl /forte/activity/→ both still 200 (Board/Activity unaffected).
Notes:
The detail pane placeholder is unconditional (never server-side "hidden"), even when
?selected=is present — there is no server-rendered populated state (per the Step 3 decision), so Stage 5's JS must additionally sync the detail pane from the URL on page load, not just on row click.
Stage 5 - Client-side controller
Changes:
Added
public/assets/paned_users_reader.js, mirroring the URL-state pattern inpaned_board_reader.js/paned_activity_reader.js: letter click toggles rowhidden+ updates?letter=viahistory.pushState; row click updates.paned-list-row--selected, updates?selected=, and fetches/api/forte_user_detail?username_token=(with a client-side cache keyed by token) to inject the returned HTML fragment into the detail pane, with a "Loading…" placeholder and a "Failed to load" fallback, same shape as Activity's commit-detail fetch/inject.popstateand initial page load both re-sync filter + selection from the URL.Registered
/assets/paned_users_reader.jsas the scripts argument inrenderForteUserDirectory()'srenderStandalonePage()call.
Verification:
node --checkon the new JS file: no syntax errors.Confirmed via
curlthat the dev server serves the fingerprinted asset (/assets/paned_users_reader.<hash>.js) at 200.Playwright-driven browser session against the local dev server (
http://127.0.0.1:8099/forte/users/):Clicking the "I" filter narrows the list to 3 visible rows and updates the URL to
?letter=I.Clicking the
ilyagrow updates the URL to?letter=I&selected=ilyag, hides the placeholder, and shows the fetched detail pane (.paned-content-subjectreads "ilyag").Browser back navigation restores the prior filter/selection state from the URL (verified across two
goBack()steps).A fresh deep-link load of
/forte/users/?letter=I&selected=ilyagreproduces the same filtered/selected state without any click.No console or page errors during the session.
Re-checked
/forte/and/forte/activity/in the same browser for console/page errors: none (Board/Activity unaffected).
Notes:
No keyboard arrow-key navigation on the filter list (Board's folder tree has this) — out of scope per Step 2's requirements, which only call for filter/select, not full keyboard parity.
Stage 6 - Regression pass + minor CSS
Changes:
Added
data-paned-users-list-paneattribute to the root oftemplates/partials/paned_user_list.php.Added a scoped rule block in
public/assets/forte.css([data-paned-users-list-pane] .paned-list-from-head, .paned-list-date-head, .paned-list-from, .paned-list-date) narrowing the reused From/Date columns to 5rem and right-aligning them for the Threads/Posts counts — the thread list's 9rem/13rem widths (built for a name and a timestamp) were oversized for two short numbers. Scoped by the new data attribute, so Board/Activity's own.paned-list-from/.paned-list-daterules are untouched.
Verification:
php -lon the changed template: no syntax errors.Playwright screenshots at 1280x800 (light and dark
colorScheme) and 400px-wide viewport of/forte/users/: 3-pane chrome renders correctly, columns are proportioned, narrow width stacks/scrolls without horizontal overflow. (This skin doesn't respond toprefers-color-scheme— confirmed Board's own screenshot is equally theme-invariant, so this matches existing behavior, not a regression.)Playwright screenshots of
/forte/and/forte/activity/at 1280x800: pixel-comparable to their pre-change appearance, confirming no regression.Playwright screenshot of
/forte/users/?letter=I&selected=ilyag: filter, listing, and detail panes all render together correctly with real data.
Notes: none
Addendum: Semantic Filter Categories (post-Step-4 revision)
Per the Step 2/Step 3 addenda, replacing the alphabetical filter with semantic categories. New Stages 7-13 below.
Stage 7 - Category data layer
Changes:
Added
fetchUserDirectoryLastActivityByToken(): arraytosrc/ForumRewrite/Application.php—MAX(posts.created_at)joined throughprofiles.identity_id, grouped up tousername_token, filtered to approved profiles and non-hidden posts.Added
buildUserDirectoryCategoryFlags(array $users, array $lastActivityByToken): array— per user, computesnew/established(established =thread_count >= 1ANDpost_count - thread_count >= 1),no_threads(thread_count === 0),recently_active(last activity within 7 days, computed via a UTCDateTimeImmutablethreshold to match theZ-suffixed ISO 8601 timestamps stored inposts.created_at).buildUserDirectoryLetterGroups()(Stage 2) is left in place, unused by anything new — removal is Stage 13's cleanup.
Verification:
php -l: no syntax errors.Reflection-based script against the real 39-user dataset: 27 "new" + 12 "established" = 39 (exhaustive, no overlap — confirmed zero rows where
new === established); zero rows foundno_threads && established(would be invalid).Hand-checked three known users:
ilyag(396 threads, 607 posts → 211 replies, last activity 2 days ago) → established + recently_active;guest(36/47 → 11 replies, 14 days ago) → established, not recently_active;test-user(25/44 → 19 replies, 23 days ago) → established, not recently_active. All matched expectations by hand.
Notes: none
Stage 8 - Semantic filter pane
Changes:
Added
buildUserDirectoryCategoryCounts(int $totalApprovedCount, array $flagsByToken, int $pendingCount): arraytoApplication.php— fixed-order category list (All Users, New, Established, No Threads, Recently Active, Not Approved) with counts, mirroringrenderForteActivity()'s$viewCountsshape. Translates Stage 7's underscore flag keys (no_threads,recently_active) to hyphenated URL/DOM keys (no-threads,recently-active) — the only two that need it.Rewrote
templates/partials/paned_users_filter_list.php: singleforeachover$categoryCounts(was per-letter),data-paned-user-categoryreplacesdata-paned-user-letter, modeled onpaned_activity_filter_list.php's fixed-view structure.
Verification:
php -lon both changed files: no syntax errors.Reflection script chained Stage 7's outputs through the new counts method:
{all:39, new:27, established:12, no-threads:10, recently-active:1, not-approved:53}— approved counts match Stage 7's verified numbers exactly; Not Approved (53) matchesfetchPendingUserDirectoryProfiles()'s own count directly.Rendered the partial via
renderFragment()withselectedCategory = 'established': the "Established" entry carriespaned-folder-item--selected,aria-selected="true",tabindex="0"; all others don't.
Notes: none
Stage 9 - Listing pane rework
Changes:
Design correction found during implementation: the Step 3 plan assumed pending users were a disjoint set from approved ones, sourced directly from
fetchPendingUserDirectoryProfiles(). Real data disproves that —username_tokenisn't unique to pending profiles (locally: "guest" has 10 duplicate pending profiles, "guest2" has 2) and an already-approved user can independently accumulate further pending profiles under their own name (locally: "ilyag", who has 396 approved threads, also has 6 pending profiles). Using the raw pending list as-is would make already-listed approved users reappear as if they were separate pending users — the opposite of "not visible anywhere else". AddedfetchNeverApprovedPendingUserDirectoryUsers()instead: rolls up pending profiles byusername_token(likefetchApprovedUserDirectoryUsers()'s ownSUM(...) GROUP BY), excluding any token that has ever been approved.Extracted row rendering into two partials mirroring
paned_activity_item_row.php/paned_activity_commit_row.php's split:templates/partials/paned_user_row.php(approved, all 5 category flags explicit) andtemplates/partials/paned_user_pending_row.php(pending, onlydata-paned-user-category-not-approved="1"set, others absent/default-false — same convention as the commit row only settingdata-paned-activity-view-commits).Rewrote
templates/partials/paned_user_list.phpas the container: loops approved users throughpaned_user_row.php, then pending users throughpaned_user_pending_row.php, appended after — both always rendered server-side, visibility toggled by category like Activity's views (no per-category round trip).Added
.paned-list-row--pendingstyling inforte.css(muted/italic username) to visually distinguish pending rows.
Verification:
php -lon all 4 changed/added files: no syntax errors.Reflection script:
fetchNeverApprovedPendingUserDirectoryUsers()returns exactly 36 (38 unique pending tokens minus the 2 that overlap with approved — "guest", "ilyag"), zero overlap with the approved token list, and "ilyag" confirmed absent from it.Rendered the list partial for
selectedCategory=all,established,not-approved: 75 total rows in markup every time (39 approved + 36 pending, both kinds always present); hidden-row counts matched hand-calculated expectations exactly for all three (all: 36 hidden = all pending;established: 63 hidden = 27 non-established approved + 36 pending;not-approved: 39 hidden = all approved).
Notes:
This changes Stage 10's data source: it should use
fetchNeverApprovedPendingUserDirectoryUsers()for its pending-token fallback, not the rawfetchPendingUserDirectoryProfiles()the Step 3 plan named.
Stage 10 - Pending-user detail pane
Changes:
Extended
handleForteUserDetail(): when no approved profile matches the token, delegates to a newhandleForteUserDetailPending(string $usernameToken, array $profiles): voidinstead of 404ing immediately.Implementation simplification vs. the plan: rather than calling
fetchNeverApprovedPendingUserDirectoryUsers()a second time,handleForteUserDetailPending()reuses the$profilesarrayhandleForteUserDetail()already fetched viafetchProfilesByUsernameToken()(every profile, any approval status, for this exact token) — one query instead of two, and the semantics line up exactly: this fallback is only reached when zero of those profiles are approved, matchingfetchNeverApprovedPendingUserDirectoryUsers()'s own exclusion rule.handleForteUserDetailPending()aggregates thread/post counts across all pending profiles sharing the token (handles the "guest"/"guest2" duplicate-profile case from Stage 9) and 404s only when the token has no profiles at all.New partial
templates/partials/paned_user_pending_detail_pane.php: username, pending profile count, aggregated thread/post counts, "not approved yet" messaging. Read-only — no approve action embedded (that stays on the existing/users/pending/admin page, keeping this endpoint out of moderation-action scope).
Verification:
php -lon both changed/added files: no syntax errors.curl /api/forte_user_detail?username_token=onthebus(single pending profile) → 200, "1 pending profile, 8 threads, 10 posts submitted so far" (matches Stage 9's verified query output for that token exactly).curl /api/forte_user_detail?username_token=guest2(2 duplicate pending profiles) → 200, correctly aggregated to "2 pending profiles, 1 thread, 1 post" (matches Stage 9'spending_profile_count: 2finding).curl /api/forte_user_detail?username_token=ilyag(approved) → still 200 with the original approved-detail fragment, unchanged.curl /api/forte_user_detail?username_token=totally-unknown-xyz→ still 404.
Notes: none
Stage 11 - Page/route wiring rework
Changes:
Added
normalizeUserDirectoryCategory(string $category): string, mirroringnormalizeActivityView()'s whitelist-or-fallback-to-'all' pattern.Rewrote
renderForteUserDirectory(): dropsrequestedLetter, computesflagsByToken,pendingUsers, andcategoryCountsup front (all rows/counts render server-side, category switching is purely client-side, same as Activity), normalizesrequestedViewvia the new method.Updated the
/forte/users/route's query param fromlettertoview.Rewrote
templates/pages/forte_users.php: passesflagsByToken/pendingUsers/categoryCounts/selectedCategoryinstead ofletterGroups/selectedLetter; status bar now reads "N users" for All, "N pending users" for Not Approved (no "of N" framing — pending users aren't a subset of the approved total), and "Showing N of M users (Label)" for the rest.
Verification:
php -lon both changed files: no syntax errors.curl /forte/users/→ "39 users".?view=established→ "Showing 12 of 39 users (Established)", filter pane's "Established" entry carriespaned-folder-item--selected.?view=not-approved→ "36 pending users".?view=bogus→ falls back to "39 users" (normalization confirmed).?view=not-approved&selected=onthebus→ the pending row carries bothpaned-list-row--pendingandpaned-list-row--selected.curl /forte/and/forte/activity/→ both still 200 (no regression). The old?letter=Iparam is now harmlessly ignored (200, falls back toall).
Notes: none
Stage 12 - Client-side controller rework
Changes:
Rewrote
public/assets/paned_users_reader.js: filter-item/row queries switched fromdata-paned-user-lettertodata-paned-user-category; row visibility now checksrow.getAttribute("data-paned-user-category-" + category) === "1"(mirroringpaned_activity_reader.js'sdata-paned-activity-view-*check exactly) instead of an equality comparison — this also means "All Users" excluding pending rows falls out of the same mechanism automatically (pending rows never carry a-category-allattribute), no special-casing needed.URL param renamed
letter→view;urlForState()omits it entirely when the category isall(was: omit when letter was empty) so default-state URLs stay clean.Status-bar text logic ported from the PHP template's three-way split (All / Not Approved / other), pulling each category's display label from its own filter-item
<span>text rather than a duplicated JS lookup table.Row-click detail-fetch logic (Stage 5) is untouched — pending rows flow through the exact same
selectUser()/showUserDetail()path as approved rows, since Stage 10's endpoint already handles both.
Verification:
node --check: no syntax errors.Playwright browser session against the local dev server: clicking "Established" narrows to 12 rows with
?view=establishedand matching status text; clicking "Not Approved" shows exactly the 36 pending rows (confirmed all visible rows carry the pending style) with?view=not-approved; clicking a pending row fetches and displays its "Pending approval" detail correctly; switching back to "All Users" hides all pending rows again (0 visible) and restores 39 visible approved rows; a deep-link to?view=recently-activereproduces the 1-row filtered state without any click; clicking "New" then browser-back restores the "All Users" URL and status text. Zero console/page errors throughout.Screenshot of
?view=not-approvedwith a pending row selected: filter pane, muted/italic pending rows, and the pending detail pane all render together correctly.
Notes: none
Stage 13 - Regression pass + cleanup
Changes:
Removed
buildUserDirectoryLetterGroups()fromApplication.php— confirmed zero remaining references to it,data-paned-user-letter,data-paned-user-row-letter,letterGroups, orselectedLetteranywhere insrc/,templates/, orpaned_users_reader.js.forte.csshad no letter-specific rules to remove — the alphabetical filter only ever reused genericpaned-folder-item/paned-folder-countclasses. Thedata-paned-users-list-panecolumn-width scoping from Stage 6 is unrelated to letters and stays.
Verification:
php -l: no syntax errors.Full curl sweep:
/forte/,/forte/activity/,/forte/users/, and/forte/users/?view=for all 5 non-default categories (new,established,no-threads,recently-active,not-approved) — all 200.Playwright sweep (9 pages: Board, Activity, Users default + all 5 categories, Users at 400px width) — zero console errors, zero failed requests, zero page errors on every page.
Screenshots confirm: "New" category (1280x800) shows the right 27 rows with correct counts in the filter pane and correct status-bar text; 400px-wide "All Users" view stacks/scrolls cleanly with no horizontal overflow.
Notes: none
Stage 14 - Fix detail-pane margins
Changes:
templates/partials/paned_user_detail_pane.phpandtemplates/partials/paned_user_pending_detail_pane.php(Stages 1/10) wrapped their content in a custompaned-user-detail-sectionclass that has no padding rule anywhere inforte.css— text sat flush against the pane edge. Board/Activity's content panes instead rely on the shared.bodyclass (padding: 0.75rem,public/assets/forte.css:389-395) for this. Replacedpaned-user-detail-sectionwith.bodyin both partials (merged into a single wrapping.bodydiv per partial, matching Board's one-.body-per-post convention) instead of adding a new padding rule for a bespoke class.
Verification:
php -lon both files: no syntax errors.Computed style check in a real browser:
.body'spadding-lefton a rendered detail pane is12px(matches Board/Activity).Screenshots of both an approved user's detail (
ilyag) and a pending user's detail (onthebus) show text properly inset from the pane edge, matching the placeholder's existing spacing.curl /forte/and/forte/activity/still 200 — no regression (neither uses this class).
Notes: none
Stage 15 - Sortable columns
Changes:
Added
resolveUserDirectorySort(),applyUserDirectorySort(),userDirectorySortValue()toApplication.php, mirroringresolveForteBoardSort()/applyForteBoardSort()/forteBoardSortValue()exactly: valid columnsusername/threads/posts, default directionascfor username anddescfor the two numeric columns, empty/invalid column means no sort (natural DB order).renderForteUserDirectory()now accepts$requestedSortColumn/$requestedSortDir, resolves them once, and applies the same sort to both$usersand$pendingUsersso ordering is consistent regardless of which category is selected. Route dispatch passes$query['sort']/$query['dir'].paned_user_row.php/paned_user_pending_row.phpgaineddata-paned-sort-username(lowercased),data-paned-sort-threads,data-paned-sort-postsattributes.paned_user_list.php's header row is now adata-paned-sort-headwith three<button data-paned-sort-column="...">cells and per-columnaria-sort, exactly matchingpaned_board_thread_list.php's head markup.paned_users_reader.js: addedcurrentSortState(),sortValueFor(),applySort(),restoreOriginalOrder()(tracksoriginalRowOrderlikepaned_board_reader.js), and asortHeadclick handler that toggles asc/desc and pushes?sort=&dir=into the URL.urlForState()now threads sort through alongside category/selection. Thepopstatehandler re-applies sort (or restores original order when the URL has none) after restoring category/selection, matching Board's popstate structure.
Verification:
php -l/node --checkon all changed files: no syntax errors.curl: default load matches the naturalthread_count DESCorder (ilyag, guest, test-user);?sort=username&dir=ascstarts at "ak";?sort=threads&dir=ascstarts at count 0;?sort=posts(nodir) rendersaria-sort="descending"(numeric default);?sort=bogusstill 200 (silently ignored, falls back to unsorted).Playwright browser session: clicking "Username" sorts ascending (starts "ak") and updates the URL; clicking again toggles descending (starts "zurcd"); clicking "Threads" defaults to descending with
ilyag(396) on top; switching to "Not Approved" while a sort is active keeps the sort applied (top pending row is "onthebus", 8 threads — the same order as unfiltered); browser back restores the prior sort's URL andaria-sort; a deep link combining?view=established&sort=posts&dir=asc&selected=guestrenders all four pieces of state correctly at once (filtered rows, ascending order, header indicator, and row selection). Zero console/page errors throughout.Screenshot of
?sort=threads&dir=desc: the Threads column header shows the ▼ indicator, matching Board's own sorted-column styling exactly.curl /forte/and/forte/activity/still 200 — no regression.
Notes: none
Stage 16 - Fix post titles in detail pane
Changes:
templates/partials/paned_user_detail_pane.php's Posts section linked each entry with$e($post['post_id'])as the visible text (the raw record id, e.g.reply-20260915111841-4f1e3273) instead of a readable title — inherited from the pre-existingforte_username.phpfull-profile page this was modeled on in Stage 1, which has the same issue. The Threads section right above it already used$threadTitle($thread)correctly. Replaced the Posts link text with$e($threadTitle($post))—ThreadTitle::displayTitle()(the closure's implementation) is generic over subject/body/fallback-id and needs no post-specific variant;fetchVisibleAuthoredPosts()already selectssubjectandbodyon every row.
Verification:
php -l: no syntax errors.curl /api/forte_user_detail?username_token=ilyag: Posts section now shows readable excerpts ("thanks", "asdfads", "A tour of Oodi", ...) instead of raw ids for every entry, including a genuine reply (created_post_id=reply-20260915111841-4f1e3273now shows "thanks", not the reply id).curl /forte/and/forte/activity/still 200 — no regression (this partial is Users-only).
Notes:
The same raw-id issue exists in the pre-existing
templates/pages/forte_username.php(the full/forte/user/{token}profile page) — out of scope here since it predates this feature and wasn't touched by it, but worth a follow-up if the user wants it fixed there too.
Stage 17 - Drop redundant "by username" in detail pane
Changes:
templates/partials/paned_user_detail_pane.php's Threads and Posts entries used$forteContentMeta($record, $field, ''), which always rendersby {author}. Since every entry on this pane is already known to be authored by the selected user (that's the page's whole subject), the author attribution was pure noise. Swapped both to$timestamp($record[$field] ?? '')— the plain date-rendering closure, no author — keeping the date, dropping "by {username}".paned_user_pending_detail_pane.phpnever had author text to begin with (pending profiles have no per-item author display), so nothing to change there.
Verification:
php -l: no syntax errors.curl /api/forte_user_detail?username_token=ilyag: every Threads and Posts entry now renders as<title> <time>...</time>with no "by ilyag" text anywhere in either section.Screenshot confirms: clean title + date per row, no redundant attribution.
curl /forte/and/forte/activity/still 200 — no regression (this partial is Users-only;$forteContentMetaitself is untouched and still used elsewhere).
Notes: none
Stage 18 - Add "Active"/"Member since" to detail-pane header
Changes:
Added
userDirectoryActivityBounds(array $threads, array $posts): arraytoApplication.php—min()/max()over each item's timestamp (ISO 8601 UTC strings sort correctly as plain strings, noDateTimeparsing needed), costing nothing beyond the thread/post lists the detail pane already fetches for its Threads/Posts sections.handleForteUserDetail(): fetches$approvedThreads/$approvedPostsonce (was callingcountVisibleAuthoredRows()separately, which internally re-fetched the same lists a second time — nowcount()on the arrays already in hand, removing a redundant query pair as a side effect), then passesactiveAt/memberSince(latest/earliest) to the partial.paned_user_detail_pane.php: added "Active {date}" and "Member since {date}" spans to the header meta row, each rendered via the existing$timestamp()closure and omitted entirely (no dangling label) when there's no visible content to compute a date from.
Verification:
php -lon both files: no syntax errors.curl /api/forte_user_detail?username_token=ilyag: header now reads "396 threads 607 posts Active Sep 20, 2026 at 01:28 UTC Member since Apr 13, 2026 at 12:50 UTC" — active date matches their most recent thread's own timestamp, member-since matches their earliest post found during Stage 7's verification.curl /api/forte_user_detail?username_token=gojosatoru(a genuine 0-thread/0-post user, found by querying forthread_count = 0 AND post_count = 0): header shows only "0 threads 0 posts" — both new spans correctly omitted, no dangling "Active"/"Member since" label.Pending-detail endpoint,
/forte/,/forte/activity/,/forte/users/all still 200.Screenshot confirms both new fields render cleanly in the header.
Notes: none
Stage 19 - Reusable relative-date format with tooltip
Changes:
Added
formatRelativeTimestamp()andrenderRelativeTimestampHtml()tosrc/ForumRewrite/View/TemplateRenderer.php, plus a new$relativeTimestampclosure alongside the existing$timestampone inrenderFile()— available to every template globally (Board/Activity included), same mechanism as$timestamp/$threadTitle/etc. "just now" / "N minutes/hours/days ago" under a week old, falling back to a bare short date ("Sep 20, 2026") past that, since e.g. "19 days ago" reads worse than the date itself.Deliberately did not touch the existing (confusingly-named)
formatFriendlyTimestamp()or$timestamp/renderTimestampHtml()— those already render the full "M j, Y at H:i UTC" form and are used across Board/Activity/Users today; changing their behavior would have been a much bigger, riskier change than asked for. The new closure is purely additive.The visible text is the short relative form; the native
titleattribute carries the full precise date (formatFriendlyTimestamp()'s existing output) as a tooltip — no JS needed, browsers rendertitlehovers for free.Applied the new
$relativeTimestampclosure withintemplates/partials/paned_user_detail_pane.php: the header's "Active"/"Member since" spans (Stage 18) and each Threads/Posts list entry's date, replacing their$timestamp(...)calls.
Verification:
php -lon both changed files: no syntax errors.curl /api/forte_user_detail?username_token=ilyag: "Active 2 days ago" (title="Sep 20, 2026 at 01:28 UTC"), "Member since Apr 13, 2026" (past the 7-day threshold, correctly falls back to the absolute short date,titlestill carries the full precise timestamp), and every Threads/Posts row now shows a relative date with the same tooltip pattern.curl /forte/: confirmed Board's own<time>tags are byte-identical to before (still the fullM j, Y at H:i UTCform, notitleattribute) — the sharedTemplateRendererchange is fully non-breaking.Playwright: read the rendered
<time>element's visible text ("2 days ago") and itstitleattribute ("Sep 20, 2026 at 01:28 UTC") directly, confirming both pieces render correctly together; zero console errors.Screenshot confirms the full pane (header + Threads list) renders cleanly with relative dates throughout.
Notes:
This closure is now available to any future template, not just Users — reusable as asked, without retrofitting Board/Activity's existing (working) date displays, which wasn't requested.
Stage 20 - Add Active/Joined columns to the listing pane
Proposed to the user first (per their ask): "Active" (last activity, already computed for the "Recently Active" filter category, just not displayed) and "Profiles" (merged-identity count, lower priority). User chose Active + a one-word rename of "Member since" → Joined, applied consistently to both the new column and the existing detail-pane header label.
Changes:
Renamed
fetchUserDirectoryLastActivityByToken()→fetchUserDirectoryActivityBoundsByToken(), now returning bothearliest/latestper token in one query (MIN/MAX(posts.created_at)) instead of just the latest — same join/filter as before, no new query cost for adding "Joined".renderForteUserDirectory()mergesactive_at/joined_atonto each$usersrow right after fetching (pending rows get''for both — see below), andbuildUserDirectoryCategoryFlags()was simplified to readactive_atstraight off each row instead of taking a second lookup parameter.Extended
resolveUserDirectorySort()/userDirectorySortValue()withactive/joinedcolumns (defaultdesc, matching the other non-username columns); ISO 8601 timestamps sort correctly as plain strings, no changes needed to the sort-application logic itself.paned_user_row.php: addeddata-paned-sort-active/data-paned-sort-joinedattributes and two new<span>cells rendered via the$relativeTimestampclosure from Stage 19.paned_user_pending_row.php: added the same attributes/cells but left blank — pending users don't have a per-token activity-bounds query (a secondary population; adding one just for two optional columns wasn't worth the extra query), so their Active/Joined cells render empty rather than guessing.paned_user_list.php: two new sortable header cells ("Active", "Joined"), matching the existing head-button/aria-sort pattern.paned_users_reader.js: addedactive: "desc", joined: "desc"tosortDefaultDir— the existing string-comparison branch insortValueFor()already handles date-string columns correctly, no other JS changes needed.paned_user_detail_pane.php: renamed the header's "Member since" label to "Joined" for consistency with the new column.forte.css: new Users-scoped column-width rules for.paned-list-active/.paned-list-joined(8rem, matching the existing pattern for Threads/Posts's 5rem scoped override).Follow-up fix found during verification: 5 columns (Username + 4 narrow ones) don't fit a 400px viewport — flexbox was squeezing the flexible Username column down to 16px (single-letter truncation) instead of overflowing, because it has
min-width: 0. Fixed by giving the list pane its own contained horizontal scroll (overflow-x: auto+ amin-width: 34remfloor on the head/row flex containers, scoped to[data-paned-users-list-pane]only) rather than letting the page scroll or the username column collapse — Username now holds a readable ~8rem minimum and the pane scrolls internally past that. Zero effect at desktop widths (the floor never binds there) and zero effect on Board/Activity (scoped selector, page-level horizontal scroll unchanged at 400×400 = 400).
Verification:
php -l/node --checkon all changed files: no syntax errors.curl /forte/users/: header row shows "Active"/"Joined" sort buttons;ilyag's row carriesdata-paned-sort-active="2026-09-20T01:28:47Z"anddata-paned-sort-joined="2026-04-13T12:50:31Z", rendered as<time>elements with relative text + tooltip, matching Stage 19's format exactly.curl /forte/users/?view=not-approved: a pending row (onthebus) has emptydata-paned-sort-active=""/data-paned-sort-joined=""and empty cells — confirmed intentional, not a bug.Playwright: clicking "Active" sorts descending with
ilyag(most recent) on top and updates?sort=active&dir=desc; clicking "Joined" sorts similarly; switching to "Not Approved" shows blank Active cells for pending rows. Zero console errors.Narrow-width regression found and fixed: before the CSS fix, the Username cell rendered at 16px (unreadable); after, 128px with full "ilyag" text visible, while
document.body.scrollWidthstayed exactly400(page-level horizontal scroll never triggered) both before and after. Desktop's list pane confirmed to have zero horizontal overflow (scrollWidth === clientWidth) after the fix — the 34rem floor doesn't bind there.curl /forte/and/forte/activity/still 200 — no regression.
Notes: none
Outstanding
All 20 stages (6 original + 7 semantic-category addendum + 1 margin fix + 1 sortable-columns addition + 1 post-title fix + 1 redundant-attribution fix + 1 active/member-since addition + 1 reusable relative-date format + 1 listing-column addition) implemented, verified, and committed. Feature complete per the Step 2 addendum's revised requirements, plus sortable columns matching Board/Activity's convention.