Skip to content

Commit eb6fd59

Browse files
committed
Enhance ROI tools with chart and CSV export
1 parent ef18965 commit eb6fd59

File tree

3 files changed

+174
-0
lines changed

3 files changed

+174
-0
lines changed

web/src/pages/ToolsPage.css

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,78 @@
7777
color: rgba(226, 232, 240, 0.95);
7878
}
7979

80+
.tools__chart-header {
81+
display: flex;
82+
justify-content: space-between;
83+
align-items: center;
84+
gap: 0.75rem;
85+
}
86+
87+
.tools__download {
88+
background: rgba(59, 130, 246, 0.15);
89+
border: 1px solid rgba(59, 130, 246, 0.45);
90+
border-radius: 0.5rem;
91+
color: rgba(191, 219, 254, 0.95);
92+
padding: 0.45rem 0.9rem;
93+
cursor: pointer;
94+
}
95+
96+
.tools__download:hover {
97+
background: rgba(59, 130, 246, 0.25);
98+
}
99+
100+
.tools__chart {
101+
width: 100%;
102+
max-width: 100%;
103+
margin-top: 1rem;
104+
}
105+
106+
.tools__chart-axis {
107+
stroke: rgba(148, 163, 184, 0.3);
108+
stroke-width: 1;
109+
}
110+
111+
.tools__chart-line {
112+
fill: none;
113+
stroke-width: 2;
114+
}
115+
116+
.tools__chart-line--gh {
117+
stroke: rgba(248, 113, 113, 0.9);
118+
}
119+
120+
.tools__chart-line--nimbus {
121+
stroke: rgba(34, 197, 94, 0.9);
122+
}
123+
124+
.tools__chart-legend {
125+
display: flex;
126+
gap: 1rem;
127+
}
128+
129+
.tools__legend-item {
130+
display: inline-flex;
131+
align-items: center;
132+
gap: 0.4rem;
133+
font-size: 0.85rem;
134+
color: rgba(226, 232, 240, 0.85);
135+
}
136+
137+
.tools__legend-swatch {
138+
display: inline-block;
139+
width: 18px;
140+
height: 3px;
141+
border-radius: 999px;
142+
}
143+
144+
.tools__legend-swatch.tools__chart-line--gh {
145+
background: rgba(248, 113, 113, 0.9);
146+
}
147+
148+
.tools__legend-swatch.tools__chart-line--nimbus {
149+
background: rgba(34, 197, 94, 0.9);
150+
}
151+
80152
.tools__next {
81153
margin: 0;
82154
padding-left: 1.2rem;
@@ -92,3 +164,7 @@
92164
border-radius: 0.3rem;
93165
font-size: 0.8rem;
94166
}
167+
168+
.tools__empty {
169+
color: rgba(148, 163, 184, 0.75);
170+
}

web/src/pages/ToolsPage.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
2329
const 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+
}

web/src/pages/__tests__/ToolsPage.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ describe('ToolsPage', () => {
1616

1717
expect(screen.getByText('Tools & ROI')).toBeInTheDocument();
1818
expect(screen.getByText(/GitHub Actions monthly cost/i)).toBeInTheDocument();
19+
expect(screen.getByRole('img', { name: /Monthly cost comparison/i })).toBeInTheDocument();
20+
expect(screen.getByText(/Download CSV/i)).toBeInTheDocument();
1921
});
2022

2123
it('updates calculations when inputs change', () => {

0 commit comments

Comments
 (0)