Creative Brain Inc. (this site) · Day 30 · May 30, 2026 · 8 min read
Building the Sprint Cost Estimator — pure logic, a share URL, and finally a test runner
How we built /sprint-estimator: a deterministic three-way cost/time calculator (fixed-price Sprint vs agency vs freelancer) backed by a single pricing source of truth, a shareable URL, and a 39-case test suite — plus the day we actually wired up vitest so those tests run.
By Andy D — Founder, Creative Brain Inc. — Brampton, Ontario

The live tool at /sprint-estimator. Two inputs (what you're building, how big),
one instant three-way comparison. Drop a screenshot at
/public/images/sprint-logs/2026-05-30-building-the-sprint-cost-estimator/sprint-estimator.png
to populate this hero.
TL;DR
- Shipped
/sprint-estimator— a free calculator that takes two inputs (project type + scope) and returns a three-way comparison: a fixed-price Creative Brain Sprint vs a traditional agency vs a freelancer, on both cost and timeline. - All the math lives in
lib/sprint-estimator.ts— pure, deterministic functions, no React and noDate.now, so the logic is trivially testable and runs identically on server or client. - Pricing comes from one place:
lib/pricing-data.tsis the single numeric source of truth for all five Sprint tiers, and/pricingwas refactored to read from it too — so the page and the calculator can never drift. - The result is shareable: inputs round-trip through
?type=&scope=, with a copy-link button, and the page seeds itself from the URL on load. - Wrote 39 tests for the logic up front — then, in a later pass, finally installed a test runner (vitest) so they actually execute. They pass; the estimator logic is at 100% statement coverage.
Wiring: standalone route only (homepage embed deliberately skipped), WebApplication JSON-LD, OG image route, sitemap entry, proxy.ts public route. No DB, no new env vars.
Why we built this
A recurring moment in early sales calls: a prospect has a quote from an agency or a number from a freelancer, and no frame of reference for ours. The honest answer to "is a $15k Sprint expensive?" is "compared to what?" — and the comparison they actually care about is total cost and time-to-live, not a line-item rate.
So the estimator is a framing tool, not a lead-gen gate. You don't enter an email. You pick what you're building, pick how ambitious it is, and you see the Sprint price next to illustrative agency and freelancer numbers for the same scope. It makes the case the sales call makes — fixed scope, fixed price, weeks not quarters — without anyone having to be on the call.
Two deliberate constraints shaped it:
- The alternatives are illustrative, and we say so. The agency and freelancer figures are derived from the Sprint price by a published multiplier, not scraped from any named firm. There's a
COMPARISON_DISCLAIMERconstant rendered on the page stating exactly that. The goal is an honest order-of-magnitude contrast, not a fake quote we'd have to defend. - One source of truth for money. Pricing that lives in two files drifts. The estimator and the
/pricingpage both readlib/pricing-data.ts, so a price change is a one-line edit in one place.
Files added or modified
New logic + data (2)
lib/sprint-estimator.ts— the pure core:estimate(),sprintPointFor(),roundToNearest(), the share-URLencodeEstimatorInput()/decodeEstimatorInput(), and the UI option metadata (PROJECT_TYPE_OPTIONS,COMPLEXITY_OPTIONS,DEFAULT_INPUT). No imports from React or the DOM.lib/pricing-data.ts— single source of truth for all five tiers (price range + duration range + display helpers) plus the comparison multipliers and disclaimer./pricingrefactored to consume it.
New route + UI (4)
app/sprint-estimator/page.tsx— server component:WebApplicationJSON-LD + breadcrumbs, seeds the client from the share URL'ssearchParams.components/tools/SprintEstimator.tsx— the brand-system client UI; live result on every change, copy-link button, GA4calculator_input_change+calculator_submitevents.app/sprint-estimator/opengraph-image.tsx— 1200×630 OG route.lib/sprint-estimator.test.ts— 39 specs (see below).
Plumbing + tooling (4)
app/sitemap.ts—/sprint-estimatorentry.proxy.ts—/sprint-estimatoradded toisPublicRoute.package.json— test scripts switched to vitest;vitest,@vitest/coverage-v8added as dev deps.vitest.config.ts— node environment,@/path alias, v8 coverage scoped to the estimator units.
Design rationale
Keep the math pure
Everything in lib/sprint-estimator.ts is a pure function of its inputs. estimate({ projectType, complexity }) returns the Sprint figure, both alternative totals, the timeline windows, and the derived savings — and the same input always yields the same output. No Date.now, no randomness, no React state reaching into the calculation.
// lib/sprint-estimator.ts (core)
export const estimate = (input: EstimatorInput): EstimatorResult => {
const tier = SPRINT_PRICING[input.projectType]
const sprintPoint = sprintPointFor(tier, input.complexity)
const sprintDays = sprintDaysFor(tier, input.complexity)
const agencyTotal = roundToNearest(sprintPoint * AGENCY_COST_MULTIPLIER)
const freelancerTotal = roundToNearest(sprintPoint * FREELANCER_COST_MULTIPLIER)
// ...returns sprint/agency/freelancer cost + timeline + savings
}
This is what makes the tool testable without rendering anything. The React component (SprintEstimator.tsx) is deliberately dumb: it reads the option metadata, calls estimate(), and paints the result. All the behavior worth testing lives in functions that never touch the DOM.
Complexity is a fraction across the tier's range
Rather than three hard-coded prices per tier, scope (small / medium / large) maps to a fraction (0 / 0.5 / 1) across the tier's published min–max. A "small" Classic Sprint lands at the $10,000 floor, "large" at the $25,000 ceiling, "medium" exactly halfway at $17,500. Flat-price tiers (the $5,000 AI Strategy Sprint, where min === max) collapse to the single number for every complexity — no special-casing needed, the same interpolation just returns the same value.
One source of truth, two consumers
lib/pricing-data.ts exports SPRINT_PRICING keyed by tier slug. The /pricing page renders the published ranges; the estimator derives its point figures from the same ranges. The comparison multipliers (AGENCY_COST_MULTIPLIER = 2.4, FREELANCER_COST_MULTIPLIER = 1.5) and the timeline windows (agency 12–24 weeks, freelancer 8–16 weeks) live here too, so the "knobs" for the illustrative figures are all in one documented spot.
Shareable by URL, no state library
The full input state is two enums, so it serializes to ?type=<slug>&scope=<complexity> and back. decodeEstimatorInput() returns null on anything missing or invalid, so the caller falls back to its own default rather than the parser guessing. That keeps the server page simple — read searchParams, decode, seed — and makes every result a copy-pasteable link. No client-side router state, no query-state library.
Honest comparison over flattering comparison
The agency/freelancer numbers are framed as illustrative and disclaimed on-page. This is the same principle as the comparison pages: a contrast a reader can trust beats a self-serving one they'll discount. The multipliers are conservative on purpose — the point is "Sprints are dramatically faster and cheaper for scoped work," which holds without inflating the alternatives.
Gotchas we hit (so the next log doesn't repeat them)
These are mostly from the day we wired up the test runner — the calculator logic was written test-first, but this repo had no runner installed (the test script pointed at jest, which wasn't in the dependencies and had no config). Getting from "specs that document intent" to "specs that actually run" surfaced four traps:
-
The peer-dep tree blocks a clean install.
npm install -D vitestfails withERESOLVEbecausevaulstill declares a React 16/17/18 peer range against this project's React 19. Fix: install dev tooling with--legacy-peer-deps(the same flag the production build already relies on here). -
Corporate TLS rejects the registry. On this Windows box the install then died with
UNABLE_TO_VERIFY_LEAF_SIGNATURE— a proxy/AV intercepting the npm registry cert. Fix: relax TLS for that one install only (--strict-ssl=false+NODE_TLS_REJECT_UNAUTHORIZED=0), then confirmnpm config get strict-sslis back totrue. Don't leave it disabled. -
vite-tsconfig-pathsis ESM-only and broke config loading. The obvious way to teach vitest the@/alias is thevite-tsconfig-pathsplugin — but it's ESM-only, and esbuild loadsvitest.config.tsviarequire, so it threw "ESM file cannot be loaded by require" and the runner wouldn't start. Fix: drop the plugin and map the alias by hand withresolve.aliaspointing@at the project root. One fewer dependency, no ESM/CJS friction. -
Tests must stay out of
tsc's sight.tsconfig.jsonexcludes**/*.test.ts/**/*.spec.tssonpm run type-checkdoesn't try to resolvedescribe/it/expectglobals (which only exist under the runner) and stays green. The specs were written with runner-agnostic globals on purpose, so they ran under vitest with zero rewrites once it was installed.
The payoff: npm run validate (format → lint → type-check → test) now actually exercises the suite instead of silently failing on a missing runner.
Verifying the work
npm run dev
curl -sI http://localhost:3000/sprint-estimator | head -1 # expect 200
# share-URL round-trip — open and confirm it seeds from the query string:
# http://localhost:3000/sprint-estimator?type=classic-sprint&scope=large
npm run test # vitest run — expect 39 passed
npm run test:ci # same, with v8 coverage
npm run type-check # stays clean — tests are excluded from tsc
The suite (lib/sprint-estimator.test.ts) covers all 15 project-type × complexity permutations for invariants (the Sprint figure always sits inside the tier's published range; alternatives never cost less than the Sprint; comparison figures are clean multiples of $500), a set of known exact values (e.g. a medium Classic Sprint = $17,500 Sprint / $42,000 agency / $26,500 freelancer), the encode→decode round-trip for every input, and the UI option metadata. Coverage on the logic itself: sprint-estimator.ts 100% statements / 100% functions / 93.75% branches, pricing-data.ts 92% statements.
What's next
- Homepage embed (C1-6) is still deliberately skipped. The estimator is standalone for now. If we want it on the homepage later, the pure-logic split means the component drops in anywhere without duplicating math.
- Tune the comparison multipliers if needed. The agency/freelancer figures are illustrative and conservative; the only knobs are
AGENCY_COST_MULTIPLIER/FREELANCER_COST_MULTIPLIERand the timeline windows inlib/pricing-data.ts. No code change beyond those constants. - Coverage thresholds in CI. Now that vitest runs, the natural next step is a
coverage.thresholdsgate so the estimator logic can't silently regress below its current bar. - More tools, same shape. The pure-logic-plus-dumb-component pattern is the template for the next free tool — write the math as testable functions first, wrap a thin UI around it second.