Repository source: docs/plans/archive/forte_activity_feed_step4_implementation_summary.md
Forte Activity Feed Step 4 Implementation Summary
Stage 1 - Backend route, controller, and server-side view/selection resolution
Changes:
src/ForumRewrite/Application.php: new route/forte/activity/(and slash-less variant) dispatches to a newrenderForteActivity(string $requestedView, string $requestedSelected). It callsfetchActivity($view)once per one of the 5 views (all/content/identity/bootstrap/approval - 5 calls total, each the same cheap indexed/LIMIT-ed query classic's ownrenderActivity()already makes for whichever single view it's showing) and merges the results into one deduplicated-by-idpool, recording per item which view(s) it appeared in (view_all/view_content/view_identity/view_bootstrap/view_approvalbooleans) and aforte_link(see Stage 2).?view=resolves via the existingnormalizeActivityView()(already used by classic, falls back toall);?selected=resolves against the resolved view's own item-id list, defaulting to that list's first (most recent) entry - both resolve-with-safe-fallback, matchingresolveForteBoardTag()/resolveForteBoardSelection()'s style. New templatetemplates/pages/forte_activity.phprenders viarenderStandalonePage(..., 'paned-reader-body', ['/assets/paned_activity_reader.js'], ['/assets/forte.css']).Deviation from the plan's stated design (caught during verification, not before writing code): the plan's Stage 1 called for a single
fetchActivity('all')superset fetch with view membership re-derived from the same predicates. That was implemented first and found wrong by direct comparison against classic: a filtered view's ownLIMIT 100window can reach further back in time than the unfilteredallwindow once enough non-matching rows crowd out its most recent items, so deriving membership from oneallfetch undercounted every filtered view (e.g. "content" showed 56 items against classic's real 100). Switched to the 5-separate-calls design described above, which reproduces classic's counts exactly (see Verification). This still adds zero new SQL -fetchActivity()/activityViewSql()are called, not modified.
Verification:
php -l: clean.Raw HTTP against a live seeded instance:
/forte/activity/returns 200, all 5 filter counts render (100/100/100/100/100 on this dataset).Cross-checked against classic directly:
curl /activity/?view={all,content,identity,bootstrap,approval}each show exactly 100<article class="card">cards - matching the new page's filter-pane counts exactly, confirming true per-view parity (not the earlier, incorrect 100/56/... split from the superset approach).?view=bogusfalls back toall(confirmed via filter-pane selection state);?view=approval&selected=<id-not-in-approval>falls back to approval's own first item rather than erroring or showing an empty detail pane.Full test suite: 399 passing / 9 failing, identical failing-test set to a stashed baseline run of unmodified
main(5 pre-existingBrowserSigningNormalizationTestNode-helper failures + 4 pre-existingLocalAppSmokeTestfailures, none touching Forte/Activity code) - no regressions.
Notes: a genuine, expected consequence of reproducing classic's per-view
LIMITwindows exactly:view_bootstrap/view_approvalare not strict subsets ofview_identityat the flag level (an item can satisfy the bootstrap predicate and rank in bootstrap's own top 100 while ranking just outside identity's own, less-restrictive top-100 cutoff). This is correct - it's the same thing that would happen if you literally opened classic's own/activity/?view=identityvs?view=bootstrapside by side - not a bug in the merge logic.
Stage 2 - Left filter pane, list pane, and detail pane markup
Changes:
New
templates/partials/paned_activity_filter_list.php: a sibling topaned_folder_tree.php(not a modification), reusing its exact CSS classes (.paned-folder-tree,.paned-folder-item,.paned-folder-item--selected,.paned-folder-count) and ARIA/roving-tabindex markup pattern, keyed ondata-paned-activity-viewinstead ofdata-paned-folder.New
templates/partials/paned_activity_item_list.php: reuses.paned-list-pane/.paned-list-head/.paned-list-rowverbatim. Deliberately reuses the existing.paned-list-from/.paned-list-subject/.paned-list-datecolumn classes (fixed 9rem / flex-fill / fixed 13rem) for a 3-column Kind/Label/Date layout instead of introducing new CSS - zero new CSS was needed for Stage 2 or 3. Each row carriesdata-paned-activity-idand the 5data-paned-activity-view-*="0"/"1"flags from Stage 1.New
templates/partials/paned_activity_detail_pane.php: reuses.paned-content-pane/.paned-content-post/.paned-content-headstructure and calls the existingpartials/source_metadata.phpverbatim with the same field setactivity.phpalready passes it (source path/href, commit sha/href truncated to 12 chars or "commit unavailable", signature path/href/status), pluskind/label/created_at/the "Author: reply-agent" badge, plus the item'sforte_link(see below).activityItemBoardLink()(Application.php) maps each item's Forte-native destination. Refinement found during headless-browser verification, not anticipated by the plan: the plan said "items whose classic destination is a board post/thread" get the Forte permalink and "other kinds" keep classic's link - read literally bykind, this sent identity/bootstrap/approval-only items (e.g.identity_bootstrap) to/forte?selected=<id>&created_post_id=<id>, but those posts are excluded from the public board by the exact same predicate the "content" view itself uses, so the permalink silently resolved to "no thread selected" - a dead end, confirmed with a headless click-through. Fixed by keying the decision on board visibility (!isHiddenBootstrapBoardTagsJson(...), the same predicatefetchActivity()'s own content filter already uses) instead ofkind: only board-visible items get the Forte permalink; everything else (identity/bootstrap/approval-only items, plusthread_label_add/site_feature_flag) keeps classic's real/posts/{id},/threads/{id}, or/tools/feature-flags/destination.
Verification:
php -l: clean.Raw HTML check: a content-visible reply's detail pane renders
<a href="/forte?selected=thread-...&created_post_id=reply-...#post-reply-...">; an identity-bootstrap item (not board-visible) renders<a href="/posts/bootstrap-...">instead - confirmed both paths render the intended link shape for their respective category.curl "/forte?selected=<thread>&created_post_id=<reply>"directly: the target thread row carriespaned-list-row--selectedand the target reply'sdata-paned-reply-post-idnode is present - confirms the Forte-permalink items truly resolve server-side (not just constructed-looking URLs).curl "/posts/bootstrap-..."returns 200 in classic - confirms the fallback destination for non-board-visible items is real, not a guess.Full test suite re-run after this stage: 399/9, same set as Stage 1 - no regressions.
Notes: none beyond the two deviations already called out above.
Stage 3 - Toolbar entry point and way back
Changes:
templates/pages/forte_board.php: added an "Activity" toolbar button immediately after "Users", exact same markup pattern as commit e0eb3b7's "Users" button (separator span +<a class="paned-toolbar-btn">+ inline 16x16 stroke-onlycurrentColorSVG +<span>label), linking to/forte/activity/. New icon is a simple clock glyph (circle + hands) to read distinctly from Users' person-pair icon.templates/pages/forte_activity.php: toolbar's first button is<a href="/forte" class="paned-toolbar-btn">with a back-arrow icon and "Board" label (a working equivalent offorte_users.php's<p class="paned-standalone-back">link, adapted to toolbar-button form since this page uses the full three-pane chrome, notforte_users.php's standalone-dialog chrome). A disabled "Refresh" button (matching the board's own disabled placeholder) fills out the toolbar.
Verification:
php -l: clean.Raw HTML check:
/fortenow contains the new "Activity" button pointing at/forte/activity/;/forte/activity/contains a "Board" button pointing at/forte.Headless click-through (Stage 4/5 tooling, reused here): loading
/forte/activity/and clicking "Board" navigates back to/fortewith no console errors.Full test suite re-run: 399/9, same set - no regressions.
Notes: none.
Stage 4 - Client-side filter switching, item selection, and keyboard nav
Changes:
New
public/assets/paned_activity_reader.js(sibling topaned_board_reader.js, that file untouched). Mirrors its structure:selectFilter(view)toggles.paned-folder-item--selected/aria-selected/tabindexon the filter items and shows/hides list rows via each row'sdata-paned-activity-view-{view}flag (no network request);selectItem(itemId)toggles the matching list row and the matching detail-pane article'shiddenattribute; both the filter list and item list getArrowUp/ArrowDownroving-tabindex keyboard handlers, directly modeled onpaned_board_reader.js's folder-tree/list-body keydown handlers (index math,.closest()for the click delegate,hidden-row skipping).
Verification (headless Chromium via puppeteer-core driving the already-installed system
chromium-browserbinary, in a throwaway scratch dir - no dependency added to the project):Clicking the "Approvals" filter updates the visible row set and the URL (
?view=approval&selected=<first-approval-item>) with zero network requests (confirmed by the URL changing viapushStatesemantics without anetworkidle-triggering navigation).Clicking a list row updates the detail pane to that exact item id (
matches: truein the check) and the URL'sselected=.ArrowDownfrom a focused, selected row moves focus, selection, and the URL to the next visible row.Browser back navigation correctly restores the prior filter+selection state.
Zero console/page errors across the full interaction sequence.
Notes:
ArrowDownfrom the last filter item correctly does nothing (clamped, matching the identical guard inpaned_board_reader.js) - initially looked like a bug in a test run but is the same intentional boundary-clamping behavior already proven in the board reader.
Stage 5 - URL sync and permalink integration verification
Changes:
paned_activity_reader.js:urlForState(view, itemId)builds/forte/activity/?view=&selected=(omittingviewwhenall, mirroring how the board reader omitstagwhen empty);pushStateIfChanged()avoids duplicate history entries; apopstatelistener re-runs the samerestoreSelectionFromUrl()used on initial load, so back/forward, a direct reload, and a shared link all converge on the same code path as a normal click.
Verification:
Direct load of
/forte/activity/?view=identity(noselected=) server-renders with "Identity" pre-selected in the filter pane and a real row pre-selected in the detail pane (Stage 1's server-side default-selection resolver) - confirmed via headless check (hasSelectedRow: true) and matches the plan's requirement that a shared/reloaded URL reproduce state without relying on client JS to "correct" it after the fact.Full click-through end-to-end: on
/forte/activity/?view=content, clicked a content item's Forte-permalink board link and landed on/forte?selected=thread-...&created_post_id=reply-...with that exact thread row selected (selectedThreadIdmatches) and the exact reply carrying.paned-highlight-new(hasHighlight: true,highlightedPostIdmatches) - the existing, unmodified permalink/highlight mechanism works unchanged for links originating from the new Activity page.Full test suite re-run (final): 399 passing / 9 failing, identical failing-test set to the pre-change baseline - no regressions from any stage.
Notes: none.