Skip to content

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.

  • 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.

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.

PlantUML diagram

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.

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.

Before opening the editor:

  1. Read the existing ItemCardsContext in src/app/items/ItemTableAGGrid.tsx (lines 58–77, ~1616–1693) and the existing SSRM datasource wherever getRows is implemented. Confirm where the eid set is available immediately after the /items/query-ssrm response resolves so the batched call can be issued before params.success fires.
  2. Read src/lib/ardaClient.ts for the existing cardsForItem helper and decide on the public signature for the new batched helper (cardsForItems(eids: string[]): Promise<KanbanCardResult[]>).
  3. Skim the failing-fetch path in getKanbanCardsForItem to confirm the IncompatibleState 500 branch is the only thing to remove (no other consumers depend on its specific shape).
  4. Sketch the extraction target — confirm ItemCardsContext is self-contained inside ItemTableAGGrid.tsx (no hidden coupling to surrounding grid code) so commit #1 below is purely mechanical.
#CommitVerification
1Extract 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
2Add 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
3In 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
4Wire 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
5Remove 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
6Add 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
7Add 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
8Extend 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)
9Remove the IncompatibleState 500 → “no cards” dead branch in getKanbanCardsForItem.Existing tests pass; no consumer of the dead branch
10Update 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
  • npm run lint
  • npx tsc --noEmit
  • npx jest --no-coverage --watchAll=false --forceExit
  • Manual smoke in make dev-mock on /items: round-trip count per page-load is 1 + 1 per SSRM block (verified in browser network panel); refresh-on-focus produces one batched refresh; scrolling a stale block triggers exactly one batched refresh.
  • 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.

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).

Before opening the editor:

  1. Read ItemDetailsPanel.tsx and identify how it tracks form dirty state. Look for: a controlled draft object in Redux (itemsSlice.drafts), local component state, or useRef-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.
  2. Find the refreshItemCards window event publisher to confirm we keep its existing semantics (still fires on mutation, panel still listens, just calls refreshCardsForItem instead of fetchCards).
  3. Decide where the banner component lives. Candidates: src/components/items/CardsUpdatedBanner.tsx (component-local) or src/components/common/StaleDataBanner.tsx (reusable for PDEV-549). Recommend the reusable form — it pays off in PDEV-549.
#CommitVerification
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
2Add 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
3Add StaleDataBanner component. Props: onRefresh, onDismiss, optional confirmDiscard callback. Sticky, dismissible.RTL test renders the three states (visible, confirming, dismissed)
4Rewire 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
5Wire 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
6Connect 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

Same as PR #1 plus a manual two-tab scenario (one tab edits, the other observes the banner appear on refresh).

  • 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.

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.

  1. Inventory all per-item cardsForItem loops in page.tsx. Confirm the three names from the Linear ticket are the complete set.
  2. Decide on the “Selection changed — refresh and retry?” banner copy and host element (toast vs. inline above the action confirmation). Recommend reusing StaleDataBanner from PDEV-548 with a different message prop.
  3. Sketch the bulk-handler extraction target. Confirm the three handlers’ dependencies (dispatch, router, toast, useItemCards, selection state) can be cleanly satisfied inside a useBulkItemActions hook so commit #1 below is mechanical rather than a redesign.
#CommitVerification
1Extract 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
2Replace 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
3Replace 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
4Update 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

Same as prior PRs.

  • 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.

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.

#CommitVerification
1Strip 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

npm run lint, npx jest src/app/api/arda/kanban/, manual.

  • Tests green.
  • No per-request log lines in normal flow.
  • PR description + CHANGELOG.
  • MSW for kanban-card responses. Mock handlers in src/mocks/handlers/kanban-queries.ts already exist for the existing routes; extend for the batched Filter.In shape.
  • Fake timers for TTL and debounce. Jest’s jest.useFakeTimers() for both the on-read coalescer and useFreshRead’s 200ms debounce.
  • Visibility API for refresh-on-focus. Mock document.visibilityState and dispatch visibilitychange events in tests.
  • rId diff fixtures. Pair of fixtures (same eid, different rId) for the banner scenarios.

Single-engineer is the default. Two cases where delegating to a front-end-engineer agent would unlock value:

  1. PDEV-235 commits 6–8 (TTL coalescer + focus handler + panel-eid extension). Bounded scope, well-specified, agent-testable. Pair with a quality-reviewer pass before merge given the concurrency surface.
  2. 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.

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.

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.

  • 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