@@ -20,6 +20,12 @@ interface RoiResult {
2020 breakevenLabel : string ;
2121}
2222
23+ interface CostPoint {
24+ runsPerDay : number ;
25+ ghMonthly : number ;
26+ nimbusMonthly : number ;
27+ }
28+
2329const DEFAULT_INPUTS : RoiInputs = {
2430 runsPerDay : 180 ,
2531 avgRuntimeMins : 12 ,
@@ -65,6 +71,19 @@ export function ToolsPage() {
6571 } ;
6672 } , [ inputs ] ) ;
6773
74+ const series = useMemo < CostPoint [ ] > ( ( ) => {
75+ const points : CostPoint [ ] = [ ] ;
76+ const maxRuns = Math . max ( inputs . runsPerDay * 1.5 , 60 ) ;
77+ const step = Math . max ( Math . round ( maxRuns / 10 ) , 10 ) ;
78+ for ( let runs = 0 ; runs <= maxRuns ; runs += step ) {
79+ const minutesPerDay = runs * inputs . avgRuntimeMins ;
80+ const ghMonthly = minutesPerDay * inputs . ghMinuteCost * 30 ;
81+ const nimbusMonthly = ( minutesPerDay * inputs . nimbusMinuteCost + ( minutesPerDay / 60 ) * inputs . hardwareCostPerHour ) * 30 ;
82+ points . push ( { runsPerDay : runs , ghMonthly, nimbusMonthly } ) ;
83+ }
84+ return points ;
85+ } , [ inputs ] ) ;
86+
6887 const handleChange = ( field : keyof RoiInputs ) => ( event : React . ChangeEvent < HTMLInputElement > ) => {
6988 const value = Number ( event . target . value ) ;
7089 setInputs ( ( prev ) => ( {
@@ -73,6 +92,23 @@ export function ToolsPage() {
7392 } ) ) ;
7493 } ;
7594
95+ const handleDownloadCsv = ( ) => {
96+ const rows = [
97+ [ "runs_per_day" , "gh_monthly_cost" , "nimbus_monthly_cost" ] ,
98+ ...series . map ( ( point ) => [ point . runsPerDay . toFixed ( 0 ) , point . ghMonthly . toFixed ( 2 ) , point . nimbusMonthly . toFixed ( 2 ) ] ) ,
99+ ] ;
100+ const csv = rows . map ( ( row ) => row . join ( "," ) ) . join ( "\n" ) ;
101+ const blob = new Blob ( [ csv ] , { type : "text/csv" } ) ;
102+ const url = URL . createObjectURL ( blob ) ;
103+ const anchor = document . createElement ( "a" ) ;
104+ anchor . href = url ;
105+ anchor . download = "nimbus-roi.csv" ;
106+ document . body . appendChild ( anchor ) ;
107+ anchor . click ( ) ;
108+ document . body . removeChild ( anchor ) ;
109+ URL . revokeObjectURL ( url ) ;
110+ } ;
111+
76112 return (
77113 < div className = "tools__container" >
78114 < header className = "tools__header" >
@@ -127,6 +163,16 @@ export function ToolsPage() {
127163 </ div >
128164 </ section >
129165
166+ < section className = "tools__section" >
167+ < div className = "tools__chart-header" >
168+ < h2 > Cost comparison</ h2 >
169+ < button type = "button" onClick = { handleDownloadCsv } className = "tools__download" >
170+ Download CSV
171+ </ button >
172+ </ div >
173+ < CostChart points = { series } />
174+ </ section >
175+
130176 < section className = "tools__section" >
131177 < h2 > Next steps</ h2 >
132178 < ul className = "tools__next" >
@@ -153,3 +199,53 @@ function ResultCard({ label, value, emphasize }: { label: string; value: string;
153199 </ article >
154200 ) ;
155201}
202+
203+ function CostChart ( { points } : { points : CostPoint [ ] } ) {
204+ if ( points . length === 0 ) {
205+ return < p className = "tools__empty" > No data.</ p > ;
206+ }
207+
208+ const width = 600 ;
209+ const height = 240 ;
210+ const padding = 40 ;
211+ const maxCost = Math . max ( ...points . map ( ( p ) => Math . max ( p . ghMonthly , p . nimbusMonthly ) ) , 1 ) ;
212+ const maxRuns = Math . max ( ...points . map ( ( p ) => p . runsPerDay ) , 1 ) ;
213+
214+ const scaleX = ( runs : number ) => padding + ( runs / maxRuns ) * ( width - padding * 2 ) ;
215+ const scaleY = ( cost : number ) => height - padding - ( cost / maxCost ) * ( height - padding * 2 ) ;
216+
217+ const toPath = ( selector : ( point : CostPoint ) => number ) =>
218+ points
219+ . map ( ( point , index ) => {
220+ const prefix = index === 0 ? "M" : "L" ;
221+ return `${ prefix } ${ scaleX ( point . runsPerDay ) } ,${ scaleY ( selector ( point ) ) } ` ;
222+ } )
223+ . join ( " " ) ;
224+
225+ return (
226+ < svg
227+ className = "tools__chart"
228+ viewBox = { `0 0 ${ width } ${ height } ` }
229+ role = "img"
230+ aria-label = "Monthly cost comparison between GitHub Actions and Nimbus"
231+ >
232+ < line x1 = { padding } y1 = { height - padding } x2 = { width - padding } y2 = { height - padding } className = "tools__chart-axis" />
233+ < line x1 = { padding } y1 = { padding } x2 = { padding } y2 = { height - padding } className = "tools__chart-axis" />
234+ < path d = { toPath ( ( p ) => p . ghMonthly ) } className = "tools__chart-line tools__chart-line--gh" />
235+ < path d = { toPath ( ( p ) => p . nimbusMonthly ) } className = "tools__chart-line tools__chart-line--nimbus" />
236+ < g className = "tools__chart-legend" transform = { `translate(${ padding } ,${ padding - 16 } )` } >
237+ < LegendSwatch className = "tools__chart-line--gh" label = "GitHub Actions" />
238+ < LegendSwatch className = "tools__chart-line--nimbus" label = "Nimbus" />
239+ </ g >
240+ </ svg >
241+ ) ;
242+ }
243+
244+ function LegendSwatch ( { className, label } : { className : string ; label : string } ) {
245+ return (
246+ < span className = "tools__legend-item" >
247+ < span className = { `tools__legend-swatch ${ className } ` } />
248+ { label }
249+ </ span >
250+ ) ;
251+ }
0 commit comments