diff --git a/data/calculator-presets.json b/data/calculator-presets.json index e60416f..707357d 100644 --- a/data/calculator-presets.json +++ b/data/calculator-presets.json @@ -1,46 +1,52 @@ [ { - "id": "solo-developer", - "name": "Solo Developer", - "description": "One builder automating repetitive coding and support tasks.", - "inputs": { - "agents": 1, - "hoursSavedPerAgentPerDay": 1.5, - "hourlyRate": 90, - "monthlyToolCosts": 149 - } + "id": "solo-builder", + "name": "Solo Builder", + "description": "One founder using a single agent stack for coding and support triage.", + "agents": 1, + "tokensPerDay": 450000, + "costPerToken": 0.0000012, + "hoursPerDay": 1.5, + "humanHourlyCost": 95 }, { - "id": "small-team", - "name": "Small Team", - "description": "A startup squad using agents across product and operations.", - "inputs": { - "agents": 5, - "hoursSavedPerAgentPerDay": 2, - "hourlyRate": 75, - "monthlyToolCosts": 699 - } + "id": "lean-startup", + "name": "Lean Startup Team", + "description": "Five agents helping product, support, and growth in a fast-moving startup.", + "agents": 5, + "tokensPerDay": 700000, + "costPerToken": 0.0000011, + "hoursPerDay": 2.2, + "humanHourlyCost": 78 }, { - "id": "enterprise", - "name": "Enterprise", - "description": "Cross-functional enterprise deployment with strict SLAs.", - "inputs": { - "agents": 40, - "hoursSavedPerAgentPerDay": 1.75, - "hourlyRate": 95, - "monthlyToolCosts": 9500 - } + "id": "agency-ops", + "name": "Agency Operations", + "description": "Client delivery team using AI agents to speed production and QA cycles.", + "agents": 12, + "tokensPerDay": 900000, + "costPerToken": 0.000001, + "hoursPerDay": 2.8, + "humanHourlyCost": 85 }, { - "id": "agency", - "name": "Agency", - "description": "Client services team using agents to speed delivery.", - "inputs": { - "agents": 12, - "hoursSavedPerAgentPerDay": 2.5, - "hourlyRate": 85, - "monthlyToolCosts": 2200 - } + "id": "enterprise-coe", + "name": "Enterprise COE", + "description": "Central AI center of excellence supporting multiple internal departments.", + "agents": 30, + "tokensPerDay": 1200000, + "costPerToken": 0.00000095, + "hoursPerDay": 1.9, + "humanHourlyCost": 105 + }, + { + "id": "support-heavy", + "name": "Support-Heavy Org", + "description": "Customer operations team running always-on assistant workflows.", + "agents": 20, + "tokensPerDay": 1600000, + "costPerToken": 0.0000009, + "hoursPerDay": 2.4, + "humanHourlyCost": 62 } ] diff --git a/src/app/api/calculator/route.ts b/src/app/api/calculator/route.ts index 188347d..d5056d3 100644 --- a/src/app/api/calculator/route.ts +++ b/src/app/api/calculator/route.ts @@ -2,97 +2,68 @@ import { promises as fs } from "fs"; import path from "path"; import { NextRequest, NextResponse } from "next/server"; -type CalculatorInputs = { - agents: number; - hoursSavedPerAgentPerDay: number; - hourlyRate: number; - monthlyToolCosts: number; -}; - -type CalculatorResults = { - monthlySavings: number; - annualRoi: number; - paybackPeriodMonths: number | null; - efficiencyGainPercent: number; -}; - -type SavedCalculation = { +type CalculatorPreset = { id: string; name: string; - inputs: CalculatorInputs; - results: CalculatorResults; - createdAt: string; + description: string; + agents: number; + tokensPerDay: number; + costPerToken: number; + hoursPerDay: number; + humanHourlyCost: number; }; -type SaveCalculationBody = { - name?: unknown; - inputs?: unknown; - results?: unknown; +type ComputeRequestBody = { + agentCount?: unknown; + tokensPerDay?: unknown; + costPerToken?: unknown; + humanEquivalentHours?: unknown; + hourlyRate?: unknown; }; const PRESETS_PATH = path.join(process.cwd(), "data", "calculator-presets.json"); -const RESULTS_PATH = path.join(process.cwd(), "data", "calculator-results.json"); +const DAYS_PER_MONTH = 30; +const WORK_DAYS_PER_MONTH = 22; function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } -function isInputs(value: unknown): value is CalculatorInputs { - if (!value || typeof value !== "object") { - return false; - } - - const inputs = value as Partial; - - return ( - isFiniteNumber(inputs.agents) && - isFiniteNumber(inputs.hoursSavedPerAgentPerDay) && - isFiniteNumber(inputs.hourlyRate) && - isFiniteNumber(inputs.monthlyToolCosts) - ); +function clamp(value: number, min: number): number { + return value < min ? min : value; } -function isResults(value: unknown): value is CalculatorResults { - if (!value || typeof value !== "object") { - return false; - } - - const results = value as Partial; - - const paybackValid = - results.paybackPeriodMonths === null || isFiniteNumber(results.paybackPeriodMonths); +async function readPresets(): Promise { + const raw = await fs.readFile(PRESETS_PATH, "utf8"); + const parsed = JSON.parse(raw) as unknown; - return ( - isFiniteNumber(results.monthlySavings) && - isFiniteNumber(results.annualRoi) && - paybackValid && - isFiniteNumber(results.efficiencyGainPercent) - ); -} - -async function readJsonFile(filePath: string, fallback: T): Promise { - try { - const raw = await fs.readFile(filePath, "utf8"); - return JSON.parse(raw) as T; - } catch { - return fallback; + if (!Array.isArray(parsed)) { + return []; } -} -async function writeResults(results: SavedCalculation[]): Promise { - const dir = path.dirname(RESULTS_PATH); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(RESULTS_PATH, JSON.stringify(results, null, 2), "utf8"); + return parsed.filter((item): item is CalculatorPreset => { + if (!item || typeof item !== "object") { + return false; + } + + const preset = item as Partial; + + return ( + typeof preset.id === "string" && + typeof preset.name === "string" && + typeof preset.description === "string" && + isFiniteNumber(preset.agents) && + isFiniteNumber(preset.tokensPerDay) && + isFiniteNumber(preset.costPerToken) && + isFiniteNumber(preset.hoursPerDay) && + isFiniteNumber(preset.humanHourlyCost) + ); + }); } export async function GET() { try { - const presets = await readJsonFile(PRESETS_PATH, []); - - if (!Array.isArray(presets)) { - return NextResponse.json({ error: "Invalid presets format" }, { status: 500 }); - } - + const presets = await readPresets(); return NextResponse.json({ presets }); } catch (error) { console.error("Failed to load calculator presets", error); @@ -102,50 +73,48 @@ export async function GET() { export async function POST(request: NextRequest) { try { - const body = (await request.json()) as SaveCalculationBody; - - const name = typeof body.name === "string" ? body.name.trim() : ""; - const inputs = body.inputs; - const results = body.results; - - if (!name) { - return NextResponse.json({ error: "name is required" }, { status: 400 }); - } - - if (!isInputs(inputs)) { - return NextResponse.json( - { error: "inputs must include agents, hoursSavedPerAgentPerDay, hourlyRate, and monthlyToolCosts" }, - { status: 400 } - ); - } - - if (!isResults(results)) { + const body = (await request.json()) as ComputeRequestBody; + + if ( + !isFiniteNumber(body.agentCount) || + !isFiniteNumber(body.tokensPerDay) || + !isFiniteNumber(body.costPerToken) || + !isFiniteNumber(body.humanEquivalentHours) || + !isFiniteNumber(body.hourlyRate) + ) { return NextResponse.json( { error: - "results must include monthlySavings, annualRoi, paybackPeriodMonths, and efficiencyGainPercent", + "agentCount, tokensPerDay, costPerToken, humanEquivalentHours, and hourlyRate must be valid numbers", }, { status: 400 } ); } - const existingResults = await readJsonFile(RESULTS_PATH, []); - const safeResults = Array.isArray(existingResults) ? existingResults : []; + const agentCount = clamp(body.agentCount, 0); + const tokensPerDay = clamp(body.tokensPerDay, 0); + const costPerToken = clamp(body.costPerToken, 0); + const humanEquivalentHours = clamp(body.humanEquivalentHours, 0); + const hourlyRate = clamp(body.hourlyRate, 0); + + const monthlyCost = agentCount * tokensPerDay * costPerToken * DAYS_PER_MONTH; + const monthlySavings = agentCount * humanEquivalentHours * hourlyRate * WORK_DAYS_PER_MONTH; - const record: SavedCalculation = { - id: `calc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - name, - inputs, - results, - createdAt: new Date().toISOString(), - }; + const netMonthly = monthlySavings - monthlyCost; + const roiPercentage = + monthlyCost > 0 ? (netMonthly / monthlyCost) * 100 : monthlySavings > 0 ? 9999 : 0; - safeResults.unshift(record); - await writeResults(safeResults); + const netDaily = netMonthly / DAYS_PER_MONTH; + const breakevenDays = netDaily > 0 && monthlyCost > 0 ? monthlyCost / netDaily : null; - return NextResponse.json({ calculation: record }, { status: 201 }); + return NextResponse.json({ + monthlyCost: Number(monthlyCost.toFixed(2)), + monthlySavings: Number(monthlySavings.toFixed(2)), + roiPercentage: Number(roiPercentage.toFixed(2)), + breakevenDays: breakevenDays === null ? null : Number(breakevenDays.toFixed(2)), + }); } catch (error) { - console.error("Failed to save calculator result", error); - return NextResponse.json({ error: "Failed to save calculator result" }, { status: 500 }); + console.error("Failed to compute calculator ROI", error); + return NextResponse.json({ error: "Failed to compute calculator ROI" }, { status: 500 }); } } diff --git a/src/app/calculator/page.tsx b/src/app/calculator/page.tsx index aad560f..10c1f24 100644 --- a/src/app/calculator/page.tsx +++ b/src/app/calculator/page.tsx @@ -2,10 +2,9 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Calculator, Loader2, Save, TrendingUp } from "lucide-react"; +import { Loader2, TrendingUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { Card, CardContent, @@ -15,82 +14,70 @@ import { } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -type CalculatorInputs = { - agents: number; - hoursSavedPerAgentPerDay: number; - hourlyRate: number; - monthlyToolCosts: number; -}; - type CalculatorPreset = { id: string; name: string; description: string; - inputs: CalculatorInputs; + agents: number; + tokensPerDay: number; + costPerToken: number; + hoursPerDay: number; + humanHourlyCost: number; +}; + +type CalculatorInputs = { + agentCount: number; + tokensPerDay: number; + costPerToken: number; + humanEquivalentHours: number; + hourlyRate: number; }; type CalculatorResults = { + monthlyCost: number; monthlySavings: number; - annualRoi: number; - paybackPeriodMonths: number | null; - efficiencyGainPercent: number; - monthlyProductivityValue: number; + roiPercentage: number; + breakevenDays: number | null; }; const DEFAULT_INPUTS: CalculatorInputs = { - agents: 5, - hoursSavedPerAgentPerDay: 2, + agentCount: 5, + tokensPerDay: 700000, + costPerToken: 0.0000011, + humanEquivalentHours: 2, hourlyRate: 75, - monthlyToolCosts: 699, }; -const WORK_DAYS_PER_MONTH = 22; -const HOURS_PER_WORK_DAY = 8; +const SAVED_PRESET_KEY = "calculator.selectedPreset"; +const SAVED_INPUTS_KEY = "calculator.inputs"; function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -function calculateResults(inputs: CalculatorInputs): CalculatorResults { - const monthlyProductivityValue = - inputs.agents * inputs.hoursSavedPerAgentPerDay * inputs.hourlyRate * WORK_DAYS_PER_MONTH; - const monthlySavings = monthlyProductivityValue - inputs.monthlyToolCosts; - const annualToolCost = inputs.monthlyToolCosts * 12; - const annualNetSavings = monthlySavings * 12; - - const annualRoi = - annualToolCost > 0 - ? (annualNetSavings / annualToolCost) * 100 - : annualNetSavings > 0 - ? 9999 - : 0; - - const paybackPeriodMonths = monthlySavings > 0 ? inputs.monthlyToolCosts / monthlySavings : null; - - const efficiencyGainPercent = (inputs.hoursSavedPerAgentPerDay / HOURS_PER_WORK_DAY) * 100; - +function toInputModel(preset: CalculatorPreset): CalculatorInputs { return { - monthlySavings, - annualRoi, - paybackPeriodMonths, - efficiencyGainPercent, - monthlyProductivityValue, + agentCount: preset.agents, + tokensPerDay: preset.tokensPerDay, + costPerToken: preset.costPerToken, + humanEquivalentHours: preset.hoursPerDay, + hourlyRate: preset.humanHourlyCost, }; } export default function CalculatorPage() { const [presets, setPresets] = useState([]); - const [activePresetId, setActivePresetId] = useState(null); + const [activePresetId, setActivePresetId] = useState(""); const [inputs, setInputs] = useState(DEFAULT_INPUTS); const [presetLoading, setPresetLoading] = useState(true); const [presetError, setPresetError] = useState(null); - const [saveLoading, setSaveLoading] = useState(false); - const [saveError, setSaveError] = useState(null); - const [saveSuccess, setSaveSuccess] = useState(null); - const results = useMemo(() => calculateResults(inputs), [inputs]); + const [results, setResults] = useState(null); + const [computeLoading, setComputeLoading] = useState(false); + const [computeError, setComputeError] = useState(null); useEffect(() => { const loadPresets = async () => { @@ -108,9 +95,31 @@ export default function CalculatorPage() { setPresets(loadedPresets); - if (loadedPresets.length > 0) { + const storedPresetId = localStorage.getItem(SAVED_PRESET_KEY); + const storedInputsRaw = localStorage.getItem(SAVED_INPUTS_KEY); + + if (storedInputsRaw) { + try { + const parsed = JSON.parse(storedInputsRaw) as Partial; + if ( + typeof parsed.agentCount === "number" && + typeof parsed.tokensPerDay === "number" && + typeof parsed.costPerToken === "number" && + typeof parsed.humanEquivalentHours === "number" && + typeof parsed.hourlyRate === "number" + ) { + setInputs(parsed as CalculatorInputs); + } + } catch { + localStorage.removeItem(SAVED_INPUTS_KEY); + } + } + + if (storedPresetId && loadedPresets.some((preset) => preset.id === storedPresetId)) { + setActivePresetId(storedPresetId); + } else if (loadedPresets.length > 0) { setActivePresetId(loadedPresets[0].id); - setInputs(loadedPresets[0].inputs); + setInputs(toInputModel(loadedPresets[0])); } } catch { setPresetError("We couldn't load presets right now. You can still use manual inputs."); @@ -122,8 +131,62 @@ export default function CalculatorPage() { void loadPresets(); }, []); + useEffect(() => { + localStorage.setItem(SAVED_INPUTS_KEY, JSON.stringify(inputs)); + }, [inputs]); + + useEffect(() => { + if (activePresetId) { + localStorage.setItem(SAVED_PRESET_KEY, activePresetId); + } + }, [activePresetId]); + + useEffect(() => { + const controller = new AbortController(); + + const compute = async () => { + try { + setComputeLoading(true); + setComputeError(null); + + const response = await fetch("/api/calculator", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + signal: controller.signal, + body: JSON.stringify(inputs), + }); + + if (!response.ok) { + throw new Error("Failed to compute ROI"); + } + + const data = (await response.json()) as CalculatorResults; + setResults(data); + } catch (error) { + if ((error as Error).name === "AbortError") { + return; + } + + setComputeError("Unable to compute ROI right now. Please adjust and try again."); + } finally { + setComputeLoading(false); + } + }; + + void compute(); + + return () => controller.abort(); + }, [inputs]); + + const activePreset = useMemo( + () => presets.find((preset) => preset.id === activePresetId) ?? null, + [presets, activePresetId] + ); + const updateInput = (key: keyof CalculatorInputs, value: number, min: number, max: number) => { - setActivePresetId(null); + setActivePresetId(""); setInputs((prev) => ({ ...prev, [key]: clamp(value, min, max), @@ -137,54 +200,14 @@ export default function CalculatorPage() { maximumFractionDigits: 0, }).format(value); - const formatPercent = (value: number) => `${value.toFixed(1)}%`; - - const handleSaveCalculation = async () => { - try { - setSaveLoading(true); - setSaveError(null); - setSaveSuccess(null); - - const presetName = presets.find((preset) => preset.id === activePresetId)?.name; - const name = presetName ? `${presetName} Snapshot` : "Custom ROI Snapshot"; - - const response = await fetch("/api/calculator", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name, - inputs, - results: { - monthlySavings: Number(results.monthlySavings.toFixed(2)), - annualRoi: Number(results.annualRoi.toFixed(2)), - paybackPeriodMonths: - results.paybackPeriodMonths === null - ? null - : Number(results.paybackPeriodMonths.toFixed(2)), - efficiencyGainPercent: Number(results.efficiencyGainPercent.toFixed(2)), - }, - }), - }); - - if (!response.ok) { - throw new Error("Failed to save calculation"); - } - - setSaveSuccess("Calculation saved successfully."); - } catch { - setSaveError("Failed to save calculation. Please try again."); - } finally { - setSaveLoading(false); - } - }; + const netMonthly = results ? results.monthlySavings - results.monthlyCost : 0; + const chartMax = Math.max(results?.monthlyCost ?? 0, results?.monthlySavings ?? 0, 1); return (
-
+
@@ -197,8 +220,8 @@ export default function CalculatorPage() {

Agent ROI Calculator

- Model your automation upside in real time. Pick a preset, tune your inputs, and save - your scenario. + Run real cost and savings math instantly. Pick a preset or tune every input for your + environment.

@@ -207,12 +230,14 @@ export default function CalculatorPage() { Inputs - Adjust your team assumptions and compare outcomes. + + Configure usage and labor assumptions to model monthly ROI. + -
-
- +
+
+ {presetLoading && ( Loading... @@ -220,125 +245,167 @@ export default function CalculatorPage() { )}
-
- {presets.map((preset) => ( - - ))} -
- {presetError &&

{presetError}

} + + + {activePreset && ( +

{activePreset.description}

+ )} + {presetError &&

{presetError}

}
updateInput("agents", value, 1, 200)} + onChange={(value) => updateInput("agentCount", value, 1, 250)} /> updateInput("tokensPerDay", value, 50000, 5000000)} + displayValue={Math.round(inputs.tokensPerDay).toLocaleString()} + /> + + updateInput("costPerToken", value, 0.0000001, 0.00002)} + displayValue={inputs.costPerToken.toFixed(7)} + /> + + updateInput("hoursSavedPerAgentPerDay", value, 0.25, 8)} + onChange={(value) => updateInput("humanEquivalentHours", value, 0.25, 10)} /> updateInput("hourlyRate", value, 15, 400)} - /> - - updateInput("monthlyToolCosts", value, 0, 100000)} + onChange={(value) => updateInput("hourlyRate", value, 15, 500)} /> - -
- - {saveError &&

{saveError}

} - {saveSuccess &&

{saveSuccess}

} -
Computed ROI - Real-time impact from your current assumptions. + Live values from the calculator API. - - - - - - - - -
-

- Formula used: agents × hours saved × hourly rate × 22 - days − monthly tool costs. -

-
+ {computeLoading && !results && ( +
+ + Computing scenario... +
+ )} + + {computeError &&

{computeError}

} + + {results && ( + <> + + + + + + + +
+
Cost Breakdown
+
+
+ Agent spend / month: {formatCurrency(results.monthlyCost)} +
+
+ Human value / month: {formatCurrency(results.monthlySavings)} +
+
+ Net monthly impact: = 0 ? "text-[#06D6A0]" : "text-red-400"}>{formatCurrency(netMonthly)} +
+
+ ROI: {results.roiPercentage.toFixed(2)}% +
+
+
+ +
+
Savings Chart
+ + +
+ +

+ Formula: monthly cost = agents × tokens/day × cost/token × 30. Monthly savings = + agents × hours/day × hourly rate × 22. +

+ + )}
@@ -348,8 +415,8 @@ export default function CalculatorPage() {

Note: This calculator provides directional ROI - estimates. Your actual outcomes depend on utilization, training quality, integration - depth, and process maturity. + estimates. Actual outcomes depend on your team's utilization, model mix, process + design, and change management.

@@ -365,14 +432,15 @@ type InputRowProps = { max: number; step: number; onChange: (value: number) => void; + displayValue?: string; }; -function InputRow({ label, value, min, max, step, onChange }: InputRowProps) { +function InputRow({ label, value, min, max, step, onChange, displayValue }: InputRowProps) { return (
-
+
- {value} + {displayValue ?? value}
{label}
@@ -418,3 +484,26 @@ function MetricTile({ label, value, accent }: MetricTileProps) {
); } + +type BarRowProps = { + label: string; + value: number; + max: number; + colorClass: string; +}; + +function BarRow({ label, value, max, colorClass }: BarRowProps) { + const widthPercent = Math.max(4, Math.min((value / max) * 100, 100)); + + return ( +
+
+ {label} + {new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value)} +
+
+
+
+
+ ); +}