Skip to content

Commit b2d31dc

Browse files
feat: add trend visualization with sparklines to perf report
- Add sparkline(), trendDirection(), trendArrow() to perf-stats.ts - Add collapsible 'Trend' section to perf-report.ts showing ASCII sparklines and directional arrows for each metric over last N commits on main - Add historical data download step to pr-perf-report.yaml from perf-data orphan branch - Switch pr-perf-report.yaml to setup-frontend action and pnpm exec - Add tests for all new functions (sparkline, trendDirection, trendArrow)
1 parent 64c852b commit b2d31dc

File tree

4 files changed

+173
-22
lines changed

4 files changed

+173
-22
lines changed

.github/workflows/pr-perf-report.yaml

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,8 @@ jobs:
2323
- name: Checkout repository
2424
uses: actions/checkout@v6
2525

26-
- name: Setup Node
27-
uses: actions/setup-node@v6
28-
with:
29-
node-version-file: '.nvmrc'
26+
- name: Setup frontend
27+
uses: ./.github/actions/setup-frontend
3028

3129
- name: Download PR metadata
3230
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
@@ -113,30 +111,19 @@ jobs:
113111
path: temp/perf-baseline/
114112
if_no_artifact_found: warn
115113

116-
- name: Load historical baselines from perf-data branch
114+
- name: Download perf history from perf-data branch
117115
if: steps.sha-check.outputs.stale != 'true'
118116
continue-on-error: true
119117
run: |
120-
mkdir -p temp/perf-history
121-
122-
git fetch origin perf-data 2>/dev/null || {
123-
echo "perf-data branch not found, skipping historical data"
124-
exit 0
125-
}
126-
127-
INDEX=0
128-
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -5); do
129-
DIR="temp/perf-history/$INDEX"
130-
mkdir -p "$DIR"
131-
git show "origin/perf-data:${file}" > "$DIR/perf-metrics.json" 2>/dev/null || true
132-
INDEX=$((INDEX + 1))
133-
done
134-
135-
echo "Loaded $INDEX historical baselines"
118+
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
119+
git fetch origin perf-data --depth=1
120+
mkdir -p temp/perf-history
121+
git archive origin/perf-data -- perf-history/ 2>/dev/null | tar -x -C temp/ 2>/dev/null || true
122+
fi
136123
137124
- name: Generate perf report
138125
if: steps.sha-check.outputs.stale != 'true'
139-
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
126+
run: pnpm exec tsx scripts/perf-report.ts > perf-report.md
140127

141128
- name: Post PR comment
142129
if: steps.sha-check.outputs.stale != 'true'

scripts/perf-report.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import {
77
computeStats,
88
formatSignificance,
99
isNoteworthy,
10+
sparkline,
11+
trendArrow,
12+
trendDirection,
1013
zScore
1114
} from './perf-stats'
1215

@@ -96,6 +99,27 @@ function getHistoricalStats(
9699
return computeStats(values)
97100
}
98101

102+
function getHistoricalTimeSeries(
103+
reports: PerfReport[],
104+
testName: string,
105+
metric: MetricKey
106+
): number[] {
107+
const sorted = [...reports].sort(
108+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
109+
)
110+
const values: number[] = []
111+
for (const r of sorted) {
112+
const group = groupByName(r.measurements)
113+
const samples = group.get(testName)
114+
if (samples) {
115+
values.push(
116+
samples.reduce((sum, s) => sum + s[metric], 0) / samples.length
117+
)
118+
}
119+
}
120+
return values
121+
}
122+
99123
function computeCV(stats: MetricStats): number {
100124
return stats.mean > 0 ? (stats.stddev / stats.mean) * 100 : 0
101125
}
@@ -227,6 +251,34 @@ function renderFullReport(
227251
}
228252
lines.push('', '</details>')
229253

254+
const trendRows: string[] = []
255+
for (const [testName] of prGroups) {
256+
for (const { key, label, unit } of REPORTED_METRICS) {
257+
const series = getHistoricalTimeSeries(historical, testName, key)
258+
if (series.length < 3) continue
259+
const dir = trendDirection(series)
260+
const arrow = trendArrow(dir)
261+
const spark = sparkline(series)
262+
const last = series[series.length - 1]
263+
trendRows.push(
264+
`| ${testName}: ${label} | ${spark} | ${arrow} | ${formatValue(last, unit)} |`
265+
)
266+
}
267+
}
268+
269+
if (trendRows.length > 0) {
270+
lines.push(
271+
'',
272+
`<details><summary>Trend (last ${historical.length} commits on main)</summary>`,
273+
'',
274+
'| Metric | Trend | Dir | Latest |',
275+
'|--------|-------|-----|--------|',
276+
...trendRows,
277+
'',
278+
'</details>'
279+
)
280+
}
281+
230282
return lines
231283
}
232284

scripts/perf-stats.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import {
55
computeStats,
66
formatSignificance,
77
isNoteworthy,
8+
sparkline,
9+
trendArrow,
10+
trendDirection,
811
zScore
912
} from './perf-stats'
1013

@@ -131,3 +134,60 @@ describe('isNoteworthy', () => {
131134
expect(isNoteworthy('noisy')).toBe(false)
132135
})
133136
})
137+
138+
describe('sparkline', () => {
139+
it('returns empty string for no values', () => {
140+
expect(sparkline([])).toBe('')
141+
})
142+
143+
it('returns mid-height for single value', () => {
144+
expect(sparkline([50])).toBe('▄')
145+
})
146+
147+
it('renders ascending values low to high', () => {
148+
const result = sparkline([0, 25, 50, 75, 100])
149+
expect(result).toBe('▁▃▅▆█')
150+
})
151+
152+
it('renders identical values as flat line', () => {
153+
const result = sparkline([10, 10, 10])
154+
expect(result).toBe('▄▄▄')
155+
})
156+
157+
it('renders descending values high to low', () => {
158+
const result = sparkline([100, 50, 0])
159+
expect(result).toBe('█▅▁')
160+
})
161+
})
162+
163+
describe('trendDirection', () => {
164+
it('returns stable for fewer than 3 values', () => {
165+
expect(trendDirection([])).toBe('stable')
166+
expect(trendDirection([1])).toBe('stable')
167+
expect(trendDirection([1, 2])).toBe('stable')
168+
})
169+
170+
it('detects rising trend', () => {
171+
expect(trendDirection([10, 10, 10, 20, 20, 20])).toBe('rising')
172+
})
173+
174+
it('detects falling trend', () => {
175+
expect(trendDirection([20, 20, 20, 10, 10, 10])).toBe('falling')
176+
})
177+
178+
it('returns stable for flat data', () => {
179+
expect(trendDirection([100, 100, 100, 100])).toBe('stable')
180+
})
181+
182+
it('returns stable for small fluctuations within 10%', () => {
183+
expect(trendDirection([100, 100, 100, 105, 105, 105])).toBe('stable')
184+
})
185+
})
186+
187+
describe('trendArrow', () => {
188+
it('returns correct emoji for each direction', () => {
189+
expect(trendArrow('rising')).toBe('📈')
190+
expect(trendArrow('falling')).toBe('📉')
191+
expect(trendArrow('stable')).toBe('➡️')
192+
})
193+
})

scripts/perf-stats.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,55 @@ export function formatSignificance(
6161
export function isNoteworthy(sig: Significance): boolean {
6262
return sig === 'regression'
6363
}
64+
65+
const SPARK_CHARS = '▁▂▃▄▅▆▇█'
66+
67+
export function sparkline(values: number[]): string {
68+
if (values.length === 0) return ''
69+
if (values.length === 1) return SPARK_CHARS[3]
70+
71+
const min = Math.min(...values)
72+
const max = Math.max(...values)
73+
const range = max - min
74+
75+
return values
76+
.map((v) => {
77+
if (range === 0) return SPARK_CHARS[3]
78+
const idx = Math.round(((v - min) / range) * (SPARK_CHARS.length - 1))
79+
return SPARK_CHARS[idx]
80+
})
81+
.join('')
82+
}
83+
84+
export type TrendDirection = 'rising' | 'falling' | 'stable'
85+
86+
export function trendDirection(values: number[]): TrendDirection {
87+
if (values.length < 3) return 'stable'
88+
89+
const half = Math.floor(values.length / 2)
90+
const firstHalf = values.slice(0, half)
91+
const secondHalf = values.slice(-half)
92+
93+
const firstMean =
94+
firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
95+
const secondMean =
96+
secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length
97+
98+
if (firstMean === 0) return 'stable'
99+
const changePct = ((secondMean - firstMean) / firstMean) * 100
100+
101+
if (changePct > 10) return 'rising'
102+
if (changePct < -10) return 'falling'
103+
return 'stable'
104+
}
105+
106+
export function trendArrow(dir: TrendDirection): string {
107+
switch (dir) {
108+
case 'rising':
109+
return '📈'
110+
case 'falling':
111+
return '📉'
112+
case 'stable':
113+
return '➡️'
114+
}
115+
}

0 commit comments

Comments
 (0)