Skip to content

Commit 751fe79

Browse files
committed
feat: add performance audit documentation and improve accessibility in CsvManager
1 parent 43b53b6 commit 751fe79

7 files changed

Lines changed: 254 additions & 126 deletions

File tree

PERFORMANCE_AUDIT.md

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# TBB Performance Audit
2+
3+
**Date:** March 28, 2026
4+
**Tool:** Chrome DevTools MCP (Lighthouse + Performance Trace)
5+
**Device:** Desktop
6+
7+
## Audit 1: Empty State (No Data Loaded)
8+
9+
**URL:** `http://localhost:5174/tbb/?period=2026-02`
10+
11+
### Lighthouse Scores
12+
13+
| Category | Score |
14+
|----------|-------|
15+
| Accessibility | ✅ 100 |
16+
| Best Practices | ⚠️ 96 |
17+
| SEO | ✅ 100 |
18+
19+
### Failed Audit: `errors-in-console`
20+
21+
```
22+
ReactDOMClient.createRoot() on a container that has already been passed to createRoot() before.
23+
```
24+
25+
React strict mode double-render issue in `main.tsx`. Likely calling `createRoot()` twice due to HMR or Vite's dev server re-executing the module. Non-critical in production but drops Best Practices to 96.
26+
27+
### Core Web Vitals (Lab)
28+
29+
| Metric | Value | Verdict |
30+
|--------|-------|---------|
31+
| LCP | 491ms | 🟢 Good |
32+
| CLS | 0.00 | 🟢 Perfect |
33+
| TTFB | 4ms | 🟢 Excellent (localhost) |
34+
35+
### LCP Breakdown
36+
37+
| Phase | Duration | % of LCP |
38+
|-------|----------|----------|
39+
| TTFB | 4ms | 0.8% |
40+
| Render Delay | 487ms | 99.2% |
41+
42+
The LCP element is text (`<p>` tag), not an image. 99% of the LCP time is render delay caused by the JS/CSS waterfall blocking first paint.
43+
44+
### Critical Rendering Path (6 levels deep)
45+
46+
```
47+
index.html (11ms)
48+
└─ main.tsx (13ms)
49+
└─ App.tsx (21ms)
50+
└─ ReportPageLayout.tsx (42ms)
51+
└─ TimeSeriesChart.tsx (96ms)
52+
└─ highcharts.js (160ms) ← longest chain endpoint
53+
```
54+
55+
**Max critical path latency: 160ms** on localhost.
56+
57+
## Audit 2: Loaded State (Data with Charts)
58+
59+
**URL:** `http://localhost:5174/tbb/?groupBy=sku&period=2026-02`
60+
**Data:** 30,541 rows across 22 SKU groups (Feb 1 to Mar 28, 2026)
61+
62+
### Lighthouse Scores
63+
64+
| Category | Score | Delta vs Empty |
65+
|----------|-------|----------------|
66+
| Accessibility | ⚠️ 98 | ↓2 |
67+
| Best Practices | ⚠️ 96 ||
68+
| SEO | ✅ 100 ||
69+
70+
### Failed Audits
71+
72+
1. **`errors-in-console`** (same as empty state)
73+
2. **`heading-order`** (NEW): `<h3>` used for hero cards ("Gross amount", "Net amount", etc.) directly after `<h1>` "Usage Report", skipping `<h2>`. Accessibility issue: heading levels should descend sequentially.
74+
75+
### Core Web Vitals (Lab)
76+
77+
| Metric | Value | Delta vs Empty | Verdict |
78+
|--------|-------|----------------|---------|
79+
| LCP | 660ms | ↑169ms (+34%) | 🟡 Slower |
80+
| CLS | 0.01 | ↑0.01 | 🟢 Still good |
81+
| TTFB | 6ms | ↑2ms | 🟢 Fine |
82+
| FCP | 620ms | N/A (new) | 🟡 Moderate |
83+
84+
### LCP Breakdown
85+
86+
| Phase | Duration | % of LCP |
87+
|-------|----------|----------|
88+
| TTFB | 6ms | 0.9% |
89+
| Render Delay | 654ms | 99.1% |
90+
91+
LCP element: `<span>` in ActionList sidebar (Primer React component). Still text, not an image. The 654ms render delay is the cost of parsing CSVs, computing aggregations, and rendering 4 Highcharts instances.
92+
93+
### 🚨 Critical: Forced Reflows from Highcharts (256ms total)
94+
95+
Highcharts is causing **256ms of forced synchronous layout** during chart rendering. The biggest offenders:
96+
97+
| Function | Time | Source |
98+
|----------|------|--------|
99+
| `getStyle()` | 151ms | `highcharts.js:1755` |
100+
| `t()` (layout calc) | 60ms | `highcharts.js:292` |
101+
| `offset()` | 50ms | `highcharts.js:351` |
102+
| `getContainer()` | 36ms | `highcharts.js:6432` |
103+
| `getBBox()` | 34ms | `highcharts.js:1706` |
104+
| `f()` | 25ms | `highcharts.js:2469` |
105+
| `updateTransform()` | 17ms | `highcharts.js:2941` |
106+
| `htmlGetBBox()` | 10ms | `highcharts.js:2898` |
107+
108+
This is Highcharts measuring SVG element dimensions (getBBox, getStyle, offset) which forces the browser to do synchronous layout. Each chart instance triggers this. With 4 charts on the page, it compounds.
109+
110+
### DOM & Memory Profile
111+
112+
| Metric | Value |
113+
|--------|-------|
114+
| Total DOM nodes | 992 (after scroll) |
115+
| SVG elements | 559 (56% of DOM!) |
116+
| Highcharts instances | 4 |
117+
| SVG paths | 318 |
118+
| SVG rects | 39 |
119+
| Stylesheets loaded | 117 |
120+
| JS heap memory | 92 MB |
121+
| Page height | 2,148px (2.3 viewports) |
122+
123+
**56% of the DOM is SVG elements from Highcharts.** This is the primary contributor to layout/style recalculation costs.
124+
125+
### Style Recalculation
126+
127+
One large style recalculation event: **60ms affecting 309 elements.** Triggered during chart rendering.
128+
129+
### Critical Path Changed: Google Fonts is the Bottleneck
130+
131+
```
132+
index.html (240ms)
133+
└─ fonts.googleapis.com/css2 (237ms)
134+
└─ fonts.gstatic.com/.../inter.woff2 (1,389ms) ← NEW longest chain!
135+
```
136+
137+
**Max critical path latency: 1,389ms** (vs 160ms in empty state). The font file download became the longest chain because the loaded state takes longer, giving more time for the font waterfall to become visible in the trace.
138+
139+
### Layout Shifts
140+
141+
CLS score: 0.01 (within "good" threshold of ≤0.1). One layout shift cluster at 3,073ms, no identified root cause. Likely triggered by chart rendering completing after initial paint.
142+
143+
### Missing Character Set Header
144+
145+
Vite dev server doesn't set `charset=utf-8` in the `Content-Type` HTTP header. The `<meta charset="UTF-8">` tag exists in `index.html` (line 4), so this is a dev-only issue. Non-issue in production if hosting sets the header.
146+
147+
## Shared Findings (Both States)
148+
149+
### CSS Request Explosion
150+
151+
**117 stylesheets** loaded, including **103+ individual CSS files** from `@primer/react/dist/`. Primer React uses granular per-component CSS files, all loaded eagerly.
152+
153+
Fine in dev (Vite serves individually with HMR), but worth verifying the production build bundles them properly.
154+
155+
### Heaviest Dependencies (Dev Bundles)
156+
157+
| File | Size |
158+
|------|------|
159+
| `client-rRt5c1ua.js` (React DOM internals) | 802 KB |
160+
| `@primer_react.js` | 703 KB |
161+
| `@primer_octicons-react.js` | 496 KB |
162+
| `highcharts.js` | 365 KB |
163+
| `SkeletonText-DzI7JYx5.js` | 305 KB |
164+
| `@primer_react_experimental.js` | 117 KB |
165+
| `@tanstack/react-table.js` | 116 KB |
166+
| `fflate.js` | 61 KB |
167+
| **Total** | **~3+ MB** |
168+
169+
### Third Parties
170+
171+
Google Fonts (`Inter`): 10 KB CSS transfer, render-blocking. The `.woff2` font file download can take up to 1,389ms and dominates the critical path.
172+
173+
### Document Latency
174+
175+
Server responded quickly, no redirects. **Compression was not applied** (Vite dev default).
176+
177+
## Prioritized Action Plan
178+
179+
### P0: Fix Heading Order (a11y)
180+
181+
Hero cards use `<h3>` directly after `<h1>`, skipping `<h2>`. Either change to `<h2>` or restructure the heading hierarchy. Restores Accessibility to 100.
182+
183+
### P1: Fix the `createRoot()` Bug
184+
185+
Guard against double-invocation in `main.tsx`. Cleans up the console error, gets Best Practices to 100.
186+
187+
### P2: Reduce Highcharts Forced Reflows (256ms)
188+
189+
This is the biggest runtime performance issue. Options:
190+
- Render charts in a `requestAnimationFrame` or `requestIdleCallback` to avoid blocking the main thread
191+
- Use `chart.reflow()` sparingly and batch DOM reads/writes
192+
- Consider setting explicit chart dimensions (`width`/`height` in options) to avoid Highcharts measuring container size
193+
- Lazy render below-the-fold charts (the Sankey and second time series chart) with IntersectionObserver
194+
195+
### P3: Lazy Load Chart Dependencies
196+
197+
`highcharts.js` (365 KB) + `highcharts/modules/sankey` (23 KB) sit at the end of the critical rendering path. Charts are only needed when data is loaded. `LazyChart.tsx` exists but chart components still eagerly import Highcharts.
198+
199+
### P4: Self-Host Google Fonts
200+
201+
Google Fonts is the #1 critical path bottleneck at **1,389ms**. The waterfall is: HTML → Google CSS → Google WOFF2 (3 hops). Self-hosting the Inter font eliminates 2 network hops and removes the render-blocking external CSS.
202+
203+
### P5: Audit Primer React Imports
204+
205+
703 KB for `@primer_react.js` + 496 KB for octicons. 117 stylesheets is excessive. Check barrel imports (`import { Button } from '@primer/react'`) vs. deep imports. Verify tree-shaking actually works in production.
206+
207+
### P6: Lazy Load File Processing Libs
208+
209+
`fflate` (61 KB), `lz-string` (14 KB), `papaparse` (25 KB) are only needed when a user drops a file or shares a URL. Dynamic `import()` on demand.
210+
211+
### P7: Virtualize SVG Elements
212+
213+
56% of the DOM (559 elements) is SVG from Highcharts. For charts with many data points, consider:
214+
- Enabling Highcharts boost module for large datasets
215+
- Using `turboThreshold` to limit rendered points
216+
- Lazy rendering below-the-fold charts with IntersectionObserver
217+
218+
### P8: Production Build Audit
219+
220+
Run `npx vite build --report` to analyze actual production bundle sizes. Dev-mode numbers are inflated. Key questions: does tree-shaking eliminate unused Primer components? Are the 117 CSS files bundled?
221+
222+
### P9: Enable Compression
223+
224+
Document response had no compression. For production, ensure gzip/brotli is enabled on hosting (automatic on Vercel/GitHub Pages).

src/components/CsvManager.module.css

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -53,36 +53,4 @@
5353
opacity: 0.5;
5454
}
5555

56-
.zipInfo {
57-
display: flex;
58-
gap: var(--base-size-12, 12px);
59-
padding: var(--base-size-16, 16px);
60-
background: var(--bgColor-muted, #f6f8fa);
61-
border: var(--borderWidth-thin, 1px) solid var(--borderColor-muted, #d1d9e0b3);
62-
border-radius: var(--borderRadius-medium, 6px);
63-
}
6456

65-
.zipInfoIcon {
66-
flex-shrink: 0;
67-
color: var(--fgColor-muted, #59636e);
68-
padding-top: 2px;
69-
}
70-
71-
.zipInfoTitle {
72-
font-weight: var(--base-text-weight-semibold, 600);
73-
font-size: var(--text-body-size-medium, 14px);
74-
margin-bottom: var(--base-size-4, 4px);
75-
}
76-
77-
.zipInfoDesc {
78-
font-size: var(--text-body-size-small, 12px);
79-
color: var(--fgColor-muted, #59636e);
80-
line-height: var(--text-body-lineHeight-medium, 20px);
81-
}
82-
83-
.zipInfoDesc code {
84-
padding: var(--base-size-2, 2px) var(--base-size-6, 6px);
85-
background: var(--bgColor-neutral-muted, #818b981f);
86-
border-radius: var(--borderRadius-small, 4px);
87-
font-size: var(--text-body-size-small, 12px);
88-
}

src/components/CsvManager.tsx

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useCallback, useRef, useMemo, useState } from 'react';
22
import {
3+
Banner,
34
Button,
45
PageHeader,
56
Text,
@@ -10,7 +11,6 @@ import {
1011
import {
1112
DownloadIcon,
1213
FileIcon,
13-
FileZipIcon,
1414
TrashIcon,
1515
UploadIcon,
1616
} from '@primer/octicons-react';
@@ -50,19 +50,18 @@ const columnHelper = createColumnHelper<FileRow>();
5050

5151
function ZipInfo() {
5252
return (
53-
<div className={styles.zipInfo}>
54-
<div className={styles.zipInfoIcon}>
55-
<FileZipIcon size={24} />
56-
</div>
57-
<div>
58-
<Text as="p" className={styles.zipInfoTitle}>ZIP backup &amp; restore</Text>
59-
<Text as="p" className={styles.zipInfoDesc}>
60-
<strong>Download all</strong> saves your reports as a single <code>github-reports-YYYY-MM-DD.zip</code> archive.
61-
To restore, just drop the ZIP file here or click <strong>Add file</strong> and select it.
62-
All CSVs inside the archive will be imported automatically.
63-
</Text>
64-
</div>
65-
</div>
53+
<Banner
54+
variant="info"
55+
title="ZIP backup & restore"
56+
description={
57+
<>
58+
<strong>Download all</strong> saves your reports as a single{' '}
59+
<code>github-reports-YYYY-MM-DD.zip</code> archive. To restore, just drop the ZIP file
60+
here or click <strong>Add file</strong> and select it. All CSVs inside the archive will
61+
be imported automatically.
62+
</>
63+
}
64+
/>
6665
);
6766
}
6867

src/components/ReportPageLayout.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
Button,
1414
Heading,
1515
IconButton,
16+
RelativeTime,
1617
Spinner,
1718
Stack,
1819
Text,
@@ -56,7 +57,7 @@ import { useHighchartsInit } from './charts/useHighchartsInit';
5657
import { getReportSchema, type ReportSchema, type MetricOption } from '../lib/report-schema';
5758
import type { ReportType } from '../lib/types';
5859
import { REPORT_TYPES } from '../lib/types';
59-
import { formatDateRange, formatDateRangeCompact, preloadBotAvatars } from '../lib/formatters';
60+
import { formatDateRange, formatDateRangeCompact, preloadBotAvatars, formatDate } from '../lib/formatters';
6061
import { computeSummary } from '../lib/aggregation';
6162
import { parseCSV } from '../lib/csv-parser';
6263
import { extractCsvsFromZip, isZipFile, ACCEPTED_FILE_TYPES } from '../lib/zip';
@@ -522,9 +523,18 @@ export function ReportPageLayout({ schema, allowedReportTypes, metricOptions }:
522523
<PageHeader.Description>
523524
<span className={styles.pageDescription}>
524525
Showing data from{' '}
525-
{activeReport
526-
? formatDateRange(activeReport.dateRange.start, activeReport.dateRange.end)
527-
: ''}
526+
{activeReport && (
527+
<>
528+
{formatDate(activeReport.dateRange.start)}{' '}
529+
<RelativeTime
530+
n datetime={activeReport.dateRange.end + 'T00:00:00'}
531+
prefix=""
532+
threshold="P30D"
533+
tense="past"
534+
noTitle
535+
/>
536+
</>
537+
)}
528538
</span>
529539
</PageHeader.Description>
530540
</PageHeader>

0 commit comments

Comments
 (0)