Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
10 KiB
PRD — Tickets List Load-Time Reduction (bundle/hydration)
Status: Draft for review Owner: TBD Created: 2026-06-15 Plan slug: 2026-06-15-tickets-list-bundle-reduction
1. Problem statement & user value
The MSP ticketing dashboard (/msp/tickets) is slow to become usable. A production
performance trace (algapsa.com, warm cache) shows:
- LCP 3,048 ms, of which 2,774 ms (91%) is render delay — the browser receives HTML quickly (TTFB 274 ms) but can't paint until it finishes executing the route's JS.
- ~9.3 MB of decoded JavaScript across 103 chunks hydrating on the list route.
- Backend is not the bottleneck: server actions return in ~24–27 ms.
- The list route eagerly bundles heavy client code that is not on screen at first paint:
- 7 dialogs imported only by the list (
BulkAssign/Tags/DueDate/Status/Priority,TicketImportDialog,TicketExportDialog). - The full
ClientDetailscomponent (2,215 lines + all client tabs) for the side-drawer quick view, even though the drawer only ever shows the first ("details") tab.
- 7 dialogs imported only by the list (
- Each list view also fires 48 redundant RSC prefetch requests (two
<Link>s per row × ~24 rows) that force the server to render ticket detail pages nobody asked for.
User value: the tickets list paints and becomes interactive noticeably faster, and the server stops doing wasted detail-page renders on every list view.
2. Goals
- G1 (primary): Reduce tickets-list initial JavaScript / hydration cost and improve LCP / render delay, measured by build output (list route first-load JS) and a repeat production-style performance trace.
- G2: Remove the redundant per-row RSC prefetch traffic from the list.
- G3: Replace the full-weight client drawer with a genuinely lightweight
ClientQuickView, and reuse it at every client quick-view call site. - G4: Move the 7 list-only dialogs out of the list route bundle via route-level code splitting (static imports only).
3. Non-goals
- No
next/dynamic/React.lazy/import()code-splitting. Architectural decision: the team avoids dynamic imports to keep module boundaries clean. - No changes to
QuickAddTicketor its rich-text editor. It is shared across 9 surfaces (including the global header quick-create), so list-only extraction yields little app-wide benefit. Tracked as a possible separate future plan. - No change to
QuickAddCategory— shared by 4 components, not cleanly list-only. - App-shell server-action burst (~20 POSTs during hydration) and the 15 s job-metrics
poll (
Header.tsx) — real but shell-level; out of scope here. - Deep-linkable / refresh-safe dialog URLs are not a goal (acceptable side effect of intercepting routes, but we will not invest extra to guarantee it).
4. Hard constraints
- C1 — No visual change for end users. Dialogs must still appear as modal overlays on top of the list (not full-page navigations). The client drawer must look identical.
- C2 — Dialogs must respect all current list filters (and, for bulk actions, the current row selection), exactly as today.
- C3 — No dynamic imports (see non-goals).
- C4 — Multi-tenant safety unchanged. This is primarily a UI/bundling refactor; any
server actions reused by new routes keep their existing
tenantscoping.
Routing decision (derived from C1 + C2 + C3): Plain separate full pages are ruled out (they change the UX from modal → full navigation, violating C1). Keeping the dialogs inside the list component keeps them in the list bundle (fails G1/G4). With dynamic imports off the table (C3), the only approach that satisfies all three is Next.js intercepting routes + a parallel
@modalslot: each dialog lives in its own route segment (so it's a separate, statically-imported route bundle) but renders as a modal overlay over the still-mounted list. This pattern does not exist in the codebase yet.
5. Affected users / flows
- MSP internal users on the tickets list: open Quick View on a client, run bulk actions on selected tickets, import/export tickets. All flows must look and behave the same.
- Any user hitting the other client quick-view surfaces (clients list, billing dashboard, ticket detail drawer, projects, interactions, user list) — they get the new lighter component with identical appearance.
6. Approach (workstreams)
A. Row-link prefetch fix (smallest, immediate)
- Add
prefetch={false}to the two<Link>s inpackages/tickets/src/lib/ticket-columns.tsx(ticket-number col ~line 219, title col ~line 273). Clicks are already intercepted (e.preventDefault()→onTicketClick), and middle-click / open-in-new-tab keep working via the retainedhref.
B. Lightweight ClientQuickView + reuse everywhere
- Rebuild
packages/clients/src/components/clients/ClientQuickView.tsxso it imports only the "details" tab content (whatClientDetailsrenders whenquickView→tabContent[0]), not the fullClientDetailsmodule (which statically imports contacts list, billing config, interactions feed, notes panel, and 3 Hudu tabs). - Extract the shared details-tab content into a small component so both
ClientDetails(full page) andClientQuickViewcan use it withoutClientQuickViewdragging in the other tabs. - Wire all client quick-view call sites to
ClientQuickView:packages/msp-composition/src/tickets/MspTicketsPageClient.tsx:18packages/msp-composition/src/tickets/MspTicketDetailsContainerClient.tsx:71packages/msp-composition/src/clients/MspClientDrawerProvider.tsx:36packages/msp-composition/src/billing/MspBillingDashboardClient.tsx:18packages/msp-composition/src/projects/MspClientIntegrationProvider.tsx:44packages/clients/src/components/clients/Clients.tsx:1779packages/clients/src/components/interactions/InteractionDetails.tsx:209packages/projects/src/components/Projects.tsx(renderClientDetails, ~1024)server/src/components/settings/general/UserList.tsx:206- (verify contact-context
<ClientDetails>usages:Contacts.tsx:697,MspContactTickets.tsx:230— classify as client quick view or leave)
C. Route-extract the 7 list-only dialogs (intercepting + parallel routes)
- Add
server/src/app/msp/tickets/layout.tsxhosting{children}+ a@modalparallel slot, andserver/src/app/msp/tickets/@modal/default.tsxreturningnull. - Create route segments for each dialog under
msp/tickets/...with an intercepting(.)variant in@modalthat renders the dialog as an overlay, plus a plain segment for the hard-load fallback. - Triggers on the list become navigations (
router.push/<Link>) to those routes instead of toggling localisOpenstate; the dialog components move into the route segments so they leave the list route's module graph. - Shared state: introduce a tickets-segment client context provider (in
tickets/layout.tsx) holding the current filters and selected ticket ids, so the intercepting modal routes (sibling subtrees that cannot read the list page's local state) can honor C2. The list writes to it; dialog routes read from it. Filters are already partly URL-synced (TicketingDashboard.tsx:510,625) and can be read fromsearchParamsas a backstop. - Dialog order by effort/benefit: Import → Export (no selection dependency, easiest, likely heaviest) first; then the 5 bulk dialogs (require the selection context).
7. Data model / API notes
No schema changes. Existing server actions (bulk assign/tags/due-date/status/priority,
import, export, client fetch) are reused unchanged and keep their tenant scoping.
Export/bulk-select-all must continue to pass the current ITicketListFilters to the
server action so results respect filters (C2).
8. Risks & mitigations
- R1 — Intercepting/parallel routes are a new pattern here. Risk of focus/scroll/
back-button regressions and SSR quirks. Mitigation: prototype one dialog (Import)
end-to-end first; add a
default.tsx; manual + e2e checks for open/close/refresh/back. - R2 — Selection-state lifting is the highest-effort item. Selection lives deep in
TicketingDashboard(selectedTicketIds, line 252). Mitigation: lift to a small tickets-scoped context; if a bulk dialog's effort:benefit is poor, it may stay in-list (re-evaluate during A/B; user scoped "all" but ROI matters). - R3 — Filter fidelity (C2). Modal routes must see the exact active filters. Mitigation: shared context is source of truth; e2e assert each dialog acts on filtered set.
- R4 —
ClientQuickViewis reused at ~10 sites. Rebuilding it risks regressions across clients/billing/projects/tickets. Mitigation: keep visual output identical to currentquickViewdetails tab; verify each call site. - R5 — "No visual change" (C1). Modal-from-route must match current modal styling/
behavior. Mitigation: reuse the existing
Dialogcomponents inside the route segments.
9. Success metrics / Definition of Done
- DoD-1: Production-style repeat trace of
/msp/ticketsshows reduced render delay and LCP vs. the 2,774 ms / 3,048 ms baseline (record exact numbers in SCRATCHPAD). - DoD-2:
next buildshows the tickets-list route first-load JS reduced; the 7 dialogs and fullClientDetailsno longer appear in the list route chunk graph. - DoD-3: Loading the list fires 0 per-row RSC prefetch requests; clicking a ticket still opens it; middle-click/open-in-new-tab still work.
- DoD-4: Every client quick-view surface renders the lightweight
ClientQuickViewand looks identical to today. - DoD-5: Each dialog opens as a modal overlay (no full-page nav), respects current filters/selection, and performs its action correctly. No visual change reported.
- DoD-6: No
next/dynamic/React.lazyintroduced.
10. Open questions
- OQ1: Confirm the intercepting-routes approach is acceptable as a new pattern (it's the only option satisfying C1+C2+C3). If not, we must relax C1 (allow full pages) or C3.
- OQ2: Is there an existing client-side store pattern (zustand/jotai/context) preferred for the tickets selection/filter context, or should we add a plain React context?
- OQ3: For bulk dialogs with poor effort:benefit, is leaving them in-list acceptable, or is full extraction of all 7 required?
- OQ4: Should
Contacts.tsx:697/MspContactTickets.tsx:230<ClientDetails>usages be migrated toClientQuickViewtoo, or are they intentionally full?