Implementation Plan: Product Slow — Front-End Items Page Performance
This is a deliberately light plan. The design is settled in
specification.md; the per-PR acceptance is settled
in the Linear tickets. The remaining planning surface is slice
boundaries: where each PR begins and ends, what order commits land in,
and which pre-work to do before opening the editor.
Why no formal task graph
Section titled “Why no formal task graph”- Single repo (
arda-frontend-app), single language, single engineer. - Four PRs in fixed stack order (PDEV-235 → 548 → 549 → 550); no parallelism opportunity inside the headline work. PDEV-550 is the only independent piece and is trivial.
- The spec is dense enough that the “what” per PR is unambiguous.
- A persona-allocated task graph and parallel worktrees would add coordination overhead without unlocking any actual parallel work.
The shape of this plan: one section per PR, each with a commit sequence, a pre-work checklist where it matters, a local verification gate, and an exit criterion for opening the PR.
Stack order (reminder)
Section titled “Stack order (reminder)”The four PRs target each other in sequence. The base of the stack is
main; each subsequent PR’s base is the branch of the PR immediately
beneath it. As each PR merges into main, the rest of the stack
rebases down. Managed with the gh-stack skill.
The shared branch jmpicnic/product-slow-fe already exists across the
three worktrees as a base for exploratory work; the per-PR branches
above are created off it (or off main) when each PR’s slice begins.
PR #1 — PDEV-235: Items page collapse
Section titled “PR #1 — PDEV-235: Items page collapse”Replace the per-row ensureCardsForItem(eid) fan-out with a single
batched Filter.In call per AG Grid SSRM block. Introduce the freshness
substrate (TTL + on-read coalescer + refresh-on-focus). Remove the
IncompatibleState 500 dead branch.
Pre-work
Section titled “Pre-work”Before opening the editor:
- Read the existing
ItemCardsContextinsrc/app/items/ItemTableAGGrid.tsx(lines 58–77, ~1616–1693) and the existing SSRM datasource wherevergetRowsis implemented. Confirm where theeidset is available immediately after the/items/query-ssrmresponse resolves so the batched call can be issued beforeparams.successfires. - Read
src/lib/ardaClient.tsfor the existingcardsForItemhelper and decide on the public signature for the new batched helper (cardsForItems(eids: string[]): Promise<KanbanCardResult[]>). - Skim the failing-fetch path in
getKanbanCardsForItemto confirm theIncompatibleState500 branch is the only thing to remove (no other consumers depend on its specific shape). - Sketch the extraction target — confirm
ItemCardsContextis self-contained insideItemTableAGGrid.tsx(no hidden coupling to surrounding grid code) so commit #1 below is purely mechanical.
Commit sequence
Section titled “Commit sequence”| # | Commit | Verification |
|---|---|---|
| 1 | Extract ItemCardsContext into its own file src/app/items/ItemCardsContext.tsx. Move the interface, provider, useItemCards hook, and existing itemCardsMap + per-item ensure/refresh state out of ItemTableAGGrid.tsx. ItemTableAGGrid.tsx imports from the new file. No behavioral change. | Typecheck clean; all existing tests pass; wc -l ItemTableAGGrid.tsx drops by ~80 LoC |
| 2 | Add cardsForItems(eids[]) to ardaClient.ts. POST /v1/kanban/kanban-card/query with Filter.In(item_reference_entity_id, eids). New unit tests for the helper. | npx jest src/lib/ardaClient.kanban.test.ts passes |
| 3 | In the new ItemCardsContext.tsx, reshape entries to { cards, fetchedAt } and add the batched methods (ensureCardsForItems, refreshCardsForItems) alongside the existing per-item ones. Keep the old methods working — no consumer changes yet. | Typecheck clean, existing tests pass |
| 4 | Wire the SSRM datasource to call ensureCardsForItems(eids) before params.success. Per-row useEffect → ensureCardsForItem in QuickActionsCell stays in place during this commit (belt-and-braces during cutover). | Manual smoke in make dev-mock: rows render with counts; network panel shows one kanban-card call per block |
| 5 | Remove the per-row useEffect → ensureCardsForItem chain in QuickActionsCell. Cells now read map[eid]?.cards synchronously. | Manual smoke: network panel shows 1 + 1 per block; no per-row calls |
| 6 | Add the on-read TTL coalescer (30s default, exposed as a constant) inside ItemCardsContext.tsx. Stale-read enqueues; microtask flush batches. Per-eid in-flight dedup. | Unit tests for coalescer: simultaneous reads coalesce, in-flight dedup, fresh reads no-op |
| 7 | Add the visibilitychange handler on the /items page that calls refreshCardsForItems(visibleEids) on hidden → visible. | Manual: alt-tab away and back; network panel shows one batched refresh |
| 8 | Extend the focus handler to include the open detail panel’s eid. Page tracks openItemEid; the focus handler unions it with visibleEids before calling refreshCardsForItems. Covers the “left panel open, switched tabs, came back” case. | Manual: open panel for item X; mutate X in another tab; refocus; the refreshed eid set includes X (verified in network panel and via the §3.4 banner once PDEV-548 lands) |
| 9 | Remove the IncompatibleState 500 → “no cards” dead branch in getKanbanCardsForItem. | Existing tests pass; no consumer of the dead branch |
| 10 | Update the ItemTableAGGrid test (and any new ItemCardsContext test file) that asserts the context provider value to reflect the new shape and new methods. | Tests pass |
Local verification gate (pre-push)
Section titled “Local verification gate (pre-push)”npm run lintnpx tsc --noEmitnpx jest --no-coverage --watchAll=false --forceExit- Manual smoke in
make dev-mockon/items: round-trip count per page-load is1 + 1per SSRM block (verified in browser network panel); refresh-on-focus produces one batched refresh; scrolling a stale block triggers exactly one batched refresh.
Exit criterion
Section titled “Exit criterion”- Local verification gate clean.
- Self-review confirms the on-read coalescer is the only refresh
mechanism beyond explicit
refresh*calls and the focus handler. - PR description includes the network-panel screenshot showing the 1+1 per block result on the 60-row test tenant, with a CHANGELOG section.
PR #2 — PDEV-548: Detail panel
Section titled “PR #2 — PDEV-548: Detail panel”Route ItemDetailsPanel through useFreshRead for instant-paint +
parallel refresh. Add the banner + [Refresh] action with unsaved-edits
confirm. Replace the panel’s local fetchCards with
refreshCardsForItem(eid).
Pre-work
Section titled “Pre-work”Before opening the editor:
- Read
ItemDetailsPanel.tsxand identify how it tracks form dirty state. Look for: a controlled draft object in Redux (itemsSlice.drafts), local component state, oruseRef-based comparison. Outcome: either a) it already exposes “is dirty” cleanly → proceed, or b) it does not → factor dirty-state into a small first commit. - Find the
refreshItemCardswindow event publisher to confirm we keep its existing semantics (still fires on mutation, panel still listens, just callsrefreshCardsForIteminstead offetchCards). - Decide where the banner component lives. Candidates:
src/components/items/CardsUpdatedBanner.tsx(component-local) orsrc/components/common/StaleDataBanner.tsx(reusable for PDEV-549). Recommend the reusable form — it pays off in PDEV-549.
Commit sequence
Section titled “Commit sequence”| # | Commit | Verification |
|---|---|---|
| 1 | (Only if pre-work flagged it) Refactor ItemDetailsPanel form-dirty tracking into a clean signal. | Existing panel tests pass; new isDirty() exported or accessible |
| 2 | Add useFreshRead hook. Reads from itemCardsMap[eid], triggers refreshCardsForItem(eid) on mount, holds paint for up to debounceMs (default 0). Returns { cards, isStale, refresh }. | Unit tests: instant cache return; 200ms debounce paints once on fast resolve, falls through on slow resolve |
| 3 | Add StaleDataBanner component. Props: onRefresh, onDismiss, optional confirmDiscard callback. Sticky, dismissible. | RTL test renders the three states (visible, confirming, dismissed) |
| 4 | Rewire ItemDetailsPanel.fetchCards callers. Replace the open-time fetchCards() with useFreshRead(item.eid, { debounceMs: 200 }). Replace mutation-completion fetchCards() calls with refreshCardsForItem(item.eid). Keep the 300/500/1000/1500ms delayed-refresh behavior intact. | Manual: panel opens with cached data, refreshes in parallel; card-move triggers same delayed refetches as today |
| 5 | Wire the rId-set diff to drive isStale. When the parallel refresh resolves and the rId set differs vs. the captured-at-open snapshot, set isStale: true. Otherwise silently update fetchedAt. | Unit tests: identical sets → no stale; differing → stale; added/removed cards → stale |
| 6 | Connect the banner. isStale → show; [Refresh] → if isDirty(), confirm; on confirm/no-dirty, apply server state to the form. | Manual scenario: open panel; in a second tab, mutate the card via API mock; refocus tab 1; banner appears; click Refresh; form updates |
Local verification gate
Section titled “Local verification gate”Same as PR #1 plus a manual two-tab scenario (one tab edits, the other observes the banner appear on refresh).
Exit criterion
Section titled “Exit criterion”- Verification gate clean.
- Two-tab scenario produces the banner reliably.
- Unsaved-edits confirm works (manual: type into a field, trigger banner, click Refresh, confirm prompt fires).
- PR description includes the scenario walkthrough and a CHANGELOG section.
PR #3 — PDEV-549: Bulk handlers
Section titled “PR #3 — PDEV-549: Bulk handlers”Split bulk handlers by mutation intent. Rewire mutating handlers
(handleDeleteMultipleItems) onto refreshCardsForItems +
rId-set check + abort-with-banner. Rewire non-mutating handlers
(handlePrintSelectedCards, handlePreviewSelectedCards) as
cache-only reads.
Pre-work
Section titled “Pre-work”- Inventory all per-item
cardsForItemloops inpage.tsx. Confirm the three names from the Linear ticket are the complete set. - Decide on the “Selection changed — refresh and retry?” banner copy
and host element (toast vs. inline above the action confirmation).
Recommend reusing
StaleDataBannerfrom PDEV-548 with a different message prop. - Sketch the bulk-handler extraction target. Confirm the three
handlers’ dependencies (
dispatch,router, toast,useItemCards, selection state) can be cleanly satisfied inside auseBulkItemActionshook so commit #1 below is mechanical rather than a redesign.
Commit sequence
Section titled “Commit sequence”| # | Commit | Verification |
|---|---|---|
| 1 | Extract bulk handlers into useBulkItemActions (src/app/items/useBulkItemActions.ts). Move handleDeleteMultipleItems, handlePrintSelectedCards, handlePreviewSelectedCards and the state they own out of page.tsx. The page destructures the handlers from the hook. No behavioral change. | Typecheck clean; existing tests pass; wc -l page.tsx drops by ~300–400 LoC |
| 2 | Replace handleDeleteMultipleItems loop (now inside the hook) with await refreshCardsForItems(selectedEids) + progress indicator + rId-set capture/compare. Abort path renders the banner. | Manual: select 3 items; in another tab, mutate one of them; trigger delete; observe abort banner |
| 3 | Replace handlePrintSelectedCards and handlePreviewSelectedCards loops with synchronous reads of itemCardsMap. | Manual: select 10 items; trigger print; one batched call (the print itself), no per-item kanban-card calls |
| 4 | Update bulk-handler tests to mock the context and assert the batched call shape. Move/copy any page-level tests into a useBulkItemActions.test.ts so the hook is independently covered. | Tests pass |
Local verification gate
Section titled “Local verification gate”Same as prior PRs.
Exit criterion
Section titled “Exit criterion”- Verification gate clean.
- Manual mutating-handler scenario reproduces the abort banner on a stale selection.
- Print/preview round-trip count matches the “no extra calls” target.
- PR description + CHANGELOG.
PR #4 — PDEV-550: BFF log cleanup
Section titled “PR #4 — PDEV-550: BFF log cleanup”Remove the 10+ console.log lines per request in
api/arda/kanban/query-details-by-item/route.ts and
api/arda/kanban/query/route.ts. Keep structured error logs.
Commit sequence
Section titled “Commit sequence”| # | Commit | Verification |
|---|---|---|
| 1 | Strip console.log calls from both route handlers. Preserve error logging in catch blocks (one log per error, message + status, no body dumps). | Existing route tests still pass; manual: trigger the routes in make dev-mock and confirm clean server output |
Local verification gate
Section titled “Local verification gate”npm run lint, npx jest src/app/api/arda/kanban/, manual.
Exit criterion
Section titled “Exit criterion”- Tests green.
- No per-request log lines in normal flow.
- PR description + CHANGELOG.
Cross-PR concerns
Section titled “Cross-PR concerns”Testing patterns we’ll lean on
Section titled “Testing patterns we’ll lean on”- MSW for kanban-card responses. Mock handlers in
src/mocks/handlers/kanban-queries.tsalready exist for the existing routes; extend for the batchedFilter.Inshape. - Fake timers for TTL and debounce. Jest’s
jest.useFakeTimers()for both the on-read coalescer anduseFreshRead’s 200ms debounce. - Visibility API for refresh-on-focus. Mock
document.visibilityStateand dispatchvisibilitychangeevents in tests. rIddiff fixtures. Pair of fixtures (sameeid, differentrId) for the banner scenarios.
When to consider agent delegation
Section titled “When to consider agent delegation”Single-engineer is the default. Two cases where delegating to a
front-end-engineer agent would unlock value:
- PDEV-235 commits 6–8 (TTL coalescer + focus handler + panel-eid
extension). Bounded scope, well-specified, agent-testable. Pair
with a
quality-reviewerpass before merge given the concurrency surface. - PDEV-548 commit 3 (banner component). Self-contained component with clean props; ideal agent task. An RTL test covering the three states is the acceptance gate.
Other PRs and commits are tightly coupled to existing code patterns and benefit more from direct authorship.
Large-file constraints
Section titled “Large-file constraints”arda-frontend-app carries historically oversized files that the team
is working to reduce. Four of the five files this stack touches are
already over 1k LoC (page.tsx 2285, columnPresets.tsx 1812,
ItemTableAGGrid.tsx 1695, ItemDetailsPanel.tsx 1201). The
extraction commits at the head of PDEV-235 (Remedy A) and PDEV-549
(Remedy B) are explicitly part of the project rather than
opportunistic cleanups — they pull ItemTableAGGrid.tsx and
page.tsx in the direction of the reduction effort and provide
focused homes for the new logic. New code added by the stack does
not land in the large files; it lands in ItemCardsContext.tsx,
useBulkItemActions.ts, and StaleDataBanner.tsx.
ItemDetailsPanel.tsx is left structurally unchanged; its net delta
from this project is small. A broader panel refactor (cards/edit/manage
tabs as sub-components) is its own project, not this one.
ardaClient.ts (879 LoC) gains ~30 LoC for cardsForItems. If a
kanban-specific extraction (src/lib/ardaClient.kanban.ts) is desired
to keep it under 1k, that’s an opportunistic addition during PDEV-235
commit 2 — not currently scheduled.
Operations#173 deploy gate
Section titled “Operations#173 deploy gate”The frontend stack can develop and locally test against MSW mocks at
any time. Do not deploy any PR in this stack to dev ahead of
operations#173 landing. Once #173 is on dev, the stack can flow
through normally. CI is unaffected.
Out of scope for this plan
Section titled “Out of scope for this plan”- Detailed test plans per PR. Each PR carries its own test additions alongside production code; the verification gates above are sufficient.
- Persona-allocated task graphs and parallel worktrees. See “Why no formal task graph” above.
- Performance measurement methodology. Use existing Sentry / CloudWatch traces; record before/after numbers in the PDEV-235 PR description.
- Rollback strategy. Standard PR revert. No data migrations, no config changes.
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved