Skip to content

perf: lazy-load recharts and @dnd-kit to cut initial bundle 42% - #13

Merged
mankatcheung merged 2 commits into
mainfrom
feat/code-splitting
Jul 19, 2026
Merged

mankatcheung merged 2 commits into
mainfrom
feat/code-splitting

Conversation

@mankatcheung

@mankatcheung mankatcheung commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

  • Extract analytics page (recharts ~350 kB) into a lazy async chunk via lazyRouteComponent
  • Extract kanban board (@dnd-kit ~45 kB) into a second lazy async chunk
  • Route stubs (analytics.tsx, board.tsx) now only contain the lazyRouteComponent call; all component logic lives in -analytics-page.tsx / -board-page.tsx (the - prefix keeps TanStack Router from treating them as route files)

Bundle size comparison

Chunk Before After
index.js (initial) 950 kB / 277 kB gzip 549 kB / 159 kB gzip
-analytics-page.js (lazy) 347 kB / 101 kB gzip
-board-page.js (lazy) 45 kB / 15 kB gzip

42% reduction in initial bundle. The heavy chunks only load when the user navigates to /analytics or /applications/board.

Test plan

  • pnpm typecheck passes
  • pnpm build produces the three chunks above with no warnings about route files
  • Navigate to /dashboard → fast load, no chart/dnd code in network tab
  • Navigate to /analytics-analytics-page chunk loads on demand, charts render
  • Navigate to /applications/board-board-page chunk loads on demand, Kanban renders and drag-and-drop works

🤖 Generated with Claude Code

https://claude.ai/code/session_01NL2sumDjFZXRxirmrjeBKc

Summary by CodeRabbit

  • New Features

    • Added an authenticated analytics page with application metrics, weekly trends, stage funnel visualisation and empty states.
    • Added a Kanban board for managing applications by status, including drag-and-drop status updates and application previews.
  • Performance

    • Analytics and application board pages now load on demand, improving initial page loading.

Extract the analytics page (recharts, ~350 kB) and the kanban board
(@dnd-kit, ~45 kB) into separate async chunks using lazyRouteComponent.

Initial bundle: 950 kB → 549 kB gzip (42% reduction).
Lazy chunks only load when the user navigates to those routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NL2sumDjFZXRxirmrjeBKc
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@mankatcheung, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 12 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 22155219-ff1c-436a-baf6-894466fbd91f

📥 Commits

Reviewing files that changed from the base of the PR and between f34e67d and 12dcd7e.

📒 Files selected for processing (1)
  • apps/web/src/graphql/client.ts

Walkthrough

Authenticated analytics and applications board implementations are moved into dedicated lazy-loaded modules. The analytics page adds application metrics and charts; the board adds drag-and-drop status updates. Generated TanStack Router metadata is reordered and the root route type entry is adjusted.

Changes

Authenticated page modules

Layer / File(s) Summary
Analytics page extraction and visualisation
apps/web/src/routes/_authenticated/-analytics-page.tsx, apps/web/src/routes/_authenticated/analytics.tsx
The analytics route lazy-loads AnalyticsPage, which queries applications, calculates metrics and funnel data, and renders loading, summary, weekly, and stage-funnel views.
Kanban board extraction and status updates
apps/web/src/routes/_authenticated/applications/-board-page.tsx, apps/web/src/routes/_authenticated/applications/board.tsx
The board route lazy-loads KanbanBoard, which renders draggable status columns and updates application status through GraphQL mutations.
Generated route metadata
apps/web/src/routeTree.gen.ts
Generated route imports, declarations, and path entries are reordered, and the root path entry no longer declares parentRoute.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AnalyticsRoute
  participant AnalyticsPage
  participant gqlClient
  participant Recharts
  AnalyticsRoute->>AnalyticsPage: lazy-load AnalyticsPage
  AnalyticsPage->>gqlClient: query AnalyticsApplications
  gqlClient-->>AnalyticsPage: return application records
  AnalyticsPage->>Recharts: render metrics and charts
Loading
sequenceDiagram
  participant BoardRoute
  participant KanbanBoard
  participant gqlClient
  participant ApplicationsQuery
  BoardRoute->>KanbanBoard: lazy-load KanbanBoard
  KanbanBoard->>gqlClient: query APPLICATIONS_QUERY
  gqlClient-->>KanbanBoard: return application records
  KanbanBoard->>gqlClient: mutate UPDATE_STATUS
  KanbanBoard->>ApplicationsQuery: invalidate applications query
Loading

Possibly related PRs

Poem

A bunny hops through charts so bright,
Then drags a card from left to right.
The stages bloom, the metrics sing,
Lazy routes make pages spring.
“Fresh status!” twitches every ear.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarises the main change: lazy-loading Recharts and @dnd-kit to reduce the initial bundle size.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/code-splitting

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/routes/_authenticated/-analytics-page.tsx`:
- Around line 84-100: Update the analytics calculations around appliedOrBeyond,
gotResponse, and successRate so gotResponse includes only definitive company
interaction statuses and excludes user-action statuses such as withdrawn.
Compute successRate using the count of actually submitted applications rather
than totalApps, while preserving the existing accepted-application numerator and
zero-denominator safeguards.

In `@apps/web/src/routes/_authenticated/applications/-board-page.tsx`:
- Around line 75-79: Update the updateStatus mutation’s onSuccess handler to
invalidate the ['analytics'] query in addition to ['applications'], ensuring
analytics metrics refresh after an application status change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4beffe3a-e92e-4f08-b012-34b51d94ff54

📥 Commits

Reviewing files that changed from the base of the PR and between 30a4409 and f34e67d.

📒 Files selected for processing (5)
  • apps/web/src/routeTree.gen.ts
  • apps/web/src/routes/_authenticated/-analytics-page.tsx
  • apps/web/src/routes/_authenticated/analytics.tsx
  • apps/web/src/routes/_authenticated/applications/-board-page.tsx
  • apps/web/src/routes/_authenticated/applications/board.tsx

Comment on lines +84 to +100
const appliedOrBeyond = apps.filter((a) => a.status !== 'draft');
const gotResponse = appliedOrBeyond.filter((a) => !['applied', 'draft'].includes(a.status));
const responseRate =
appliedOrBeyond.length > 0
? Math.round((gotResponse.length / appliedOrBeyond.length) * 100)
: 0;

const totalApps = apps.length;
const activeApps = apps.filter((a) =>
['applied', 'interviewing', 'offered'].includes(a.status),
).length;
const successRate =
totalApps > 0
? Math.round(
(apps.filter((a) => a.status === 'accepted').length / totalApps) * 100,
)
: 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Refine response and success rate calculations.

The current gotResponse calculation considers withdrawn applications as responses. Withdrawals are user actions, not company responses. Additionally, successRate is calculated using totalApps (which includes draft applications), artificially lowering the rate of successful submissions.

Base the response rate purely on definitive company interactions and base the success rate on applications that were actually submitted.

💡 Proposed fixes for metric derivation
   const appliedOrBeyond = apps.filter((a) => a.status !== 'draft');
-  const gotResponse = appliedOrBeyond.filter((a) => !['applied', 'draft'].includes(a.status));
+  const gotResponse = appliedOrBeyond.filter((a) =>
+    ['interviewing', 'offered', 'accepted', 'rejected'].includes(a.status)
+  );
   const responseRate =
     appliedOrBeyond.length > 0
       ? Math.round((gotResponse.length / appliedOrBeyond.length) * 100)
       : 0;
 
   const totalApps = apps.length;
   const activeApps = apps.filter((a) =>
     ['applied', 'interviewing', 'offered'].includes(a.status),
   ).length;
   const successRate =
-    totalApps > 0
+    appliedOrBeyond.length > 0
       ? Math.round(
-          (apps.filter((a) => a.status === 'accepted').length / totalApps) * 100,
+          (apps.filter((a) => a.status === 'accepted').length / appliedOrBeyond.length) * 100,
         )
       : 0;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const appliedOrBeyond = apps.filter((a) => a.status !== 'draft');
const gotResponse = appliedOrBeyond.filter((a) => !['applied', 'draft'].includes(a.status));
const responseRate =
appliedOrBeyond.length > 0
? Math.round((gotResponse.length / appliedOrBeyond.length) * 100)
: 0;
const totalApps = apps.length;
const activeApps = apps.filter((a) =>
['applied', 'interviewing', 'offered'].includes(a.status),
).length;
const successRate =
totalApps > 0
? Math.round(
(apps.filter((a) => a.status === 'accepted').length / totalApps) * 100,
)
: 0;
const appliedOrBeyond = apps.filter((a) => a.status !== 'draft');
const gotResponse = appliedOrBeyond.filter((a) =>
['interviewing', 'offered', 'accepted', 'rejected'].includes(a.status)
);
const responseRate =
appliedOrBeyond.length > 0
? Math.round((gotResponse.length / appliedOrBeyond.length) * 100)
: 0;
const totalApps = apps.length;
const activeApps = apps.filter((a) =>
['applied', 'interviewing', 'offered'].includes(a.status),
).length;
const successRate =
appliedOrBeyond.length > 0
? Math.round(
(apps.filter((a) => a.status === 'accepted').length / appliedOrBeyond.length) * 100,
)
: 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/routes/_authenticated/-analytics-page.tsx` around lines 84 -
100, Update the analytics calculations around appliedOrBeyond, gotResponse, and
successRate so gotResponse includes only definitive company interaction statuses
and excludes user-action statuses such as withdrawn. Compute successRate using
the count of actually submitted applications rather than totalApps, while
preserving the existing accepted-application numerator and zero-denominator
safeguards.

Comment on lines +75 to +79
const updateStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
gqlClient.request(UPDATE_STATUS, { id, input: { status } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['applications'] }),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Invalidate the analytics query on status update.

Updating an application's status alters the data used to compute the metrics on the Analytics page. Invalidate the ['analytics'] query alongside ['applications'] to prevent users from seeing stale funnel and metric data if they navigate to the analytics dashboard immediately after dropping a card.

🔄 Proposed fix
   const updateStatus = useMutation({
     mutationFn: ({ id, status }: { id: string; status: string }) =>
       gqlClient.request(UPDATE_STATUS, { id, input: { status } }),
-    onSuccess: () => qc.invalidateQueries({ queryKey: ['applications'] }),
+    onSuccess: () => {
+      qc.invalidateQueries({ queryKey: ['applications'] });
+      qc.invalidateQueries({ queryKey: ['analytics'] });
+    },
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const updateStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
gqlClient.request(UPDATE_STATUS, { id, input: { status } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['applications'] }),
});
const updateStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
gqlClient.request(UPDATE_STATUS, { id, input: { status } }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['applications'] });
qc.invalidateQueries({ queryKey: ['analytics'] });
},
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/routes/_authenticated/applications/-board-page.tsx` around lines
75 - 79, Update the updateStatus mutation’s onSuccess handler to invalidate the
['analytics'] query in addition to ['applications'], ensuring analytics metrics
refresh after an application status change.

When the access token expires:
- If refresh succeeds: invalidate all TanStack Query cache so active
  queries re-run with the new token (previously they stayed in error state)
- If refresh fails: clear the query cache before redirecting to /login
  so stale data doesn't bleed into the next session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NL2sumDjFZXRxirmrjeBKc
@mankatcheung
mankatcheung merged commit 4c52bd6 into main Jul 19, 2026
2 checks passed
@mankatcheung
mankatcheung deleted the feat/code-splitting branch July 19, 2026 14:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant