A full-stack calculator: a React + TypeScript SPA talking to a Go REST API.
Basic and advanced arithmetic (add, subtract, multiply, divide, power,
square root, percentage), strict input validation, a keyboard-accessible
UI with a light/dark theme toggle, Windows-Calculator-style operator
chaining (5 × 3 + evaluates 5 × 3 immediately, each step a real API
call), and a non-blocking, persisted calculation history panel. Deployable
via Docker Compose or Vercel.
- Architecture
- Getting started
- Deployment (Vercel)
- API
- Sequence: a calculation request
- Use cases
- Backend components (optional detail)
- Design decisions and trade-offs
- Assumptions
- Testing and coverage
- Code quality and complexity
- Project structure
- CI
flowchart TD
User(["User"])
subgraph Frontend["Frontend — React SPA (Vite + TypeScript)"]
UI["Calculator UI (components + useCalculator hook)"]
Api["calculatorApi.ts (HTTP client)"]
UI --> Api
end
subgraph Backend["Backend — Go REST API"]
Handler["HTTP handlers (net/http)"]
Logic["calculator package (pure arithmetic)"]
Handler --> Logic
end
User -->|interacts with| UI
Api -->|"POST /api/v1/calculate (JSON)"| Handler
Backend (requires Go 1.22+):
cd backend
go run ./cmd/serverThe server listens on :8080 by default. Configurable via environment
variables:
| Variable | Default | Purpose |
|---|---|---|
PORT |
8080 |
Port the server listens on |
CORS_ALLOWED_ORIGIN |
http://localhost:5173 |
Value sent in Access-Control-Allow-Origin |
Frontend (requires Node 20+):
cd frontend
npm install
npm run devOpens on http://localhost:5173 by default (Vite). To point the frontend
at a backend running somewhere other than http://localhost:8080/api/v1,
set VITE_API_BASE_URL (e.g. in frontend/.env.local).
docker compose up --buildThis builds and starts both services:
- Backend at
http://localhost:8080 - Frontend at
http://localhost:5173
Stop with docker compose down.
Live at cordeirops.xyz.
The live deployment splits the monorepo into two separate Vercel projects pointed at the same GitHub repo, unified under one custom domain:
- Backend — Root Directory
backend. Vercel's Go builder runscmd/serverdirectly (a plainnet/http.ListenAndServeserver, no serverless-function adapter needed). SetCORS_ALLOWED_ORIGINto the frontend's deployed origin. - Frontend — Root Directory
frontend, framework preset Vite.frontend/vercel.jsonrewrites/api/*to the backend's Vercel URL, so the browser only ever talks to the frontend's own origin — the request becomes same-origin and CORS never enters the picture for the live domain. SetVITE_API_BASE_URLto/api/v1(relative, not the backend's full URL) so this rewrite is what's actually used in production.
Both env vars are build/runtime configuration only — no code changes are needed to move between local, Docker, and Vercel.
Base URL: http://localhost:8080/api/v1. Full machine-readable spec:
openapi.yaml.
curl -s http://localhost:8080/api/v1/health{ "status": "ok" }Single endpoint for every operation; the operation is selected in the request body rather than via the URL path (see Design decisions).
| Operation | Operands | Semantics |
|---|---|---|
add |
2 | a + b |
subtract |
2 | a - b |
multiply |
2 | a * b |
divide |
2 | a / b |
power |
2 | a ^ b |
sqrt |
1 | √a |
percentage |
2 | (a / 100) * b — "a% of b" |
Success examples:
curl -s -X POST http://localhost:8080/api/v1/calculate \
-H "Content-Type: application/json" \
-d '{"operation":"add","operands":[2,3]}'
# {"operation":"add","operands":[2,3],"result":5}
curl -s -X POST http://localhost:8080/api/v1/calculate \
-H "Content-Type: application/json" \
-d '{"operation":"divide","operands":[10,4]}'
# {"operation":"divide","operands":[10,4],"result":2.5}
curl -s -X POST http://localhost:8080/api/v1/calculate \
-H "Content-Type: application/json" \
-d '{"operation":"sqrt","operands":[144]}'
# {"operation":"sqrt","operands":[144],"result":12}
curl -s -X POST http://localhost:8080/api/v1/calculate \
-H "Content-Type: application/json" \
-d '{"operation":"percentage","operands":[50,200]}'
# {"operation":"percentage","operands":[50,200],"result":100}Error example — division by zero (422):
curl -s -X POST http://localhost:8080/api/v1/calculate \
-H "Content-Type: application/json" \
-d '{"operation":"divide","operands":[10,0]}'{ "error": { "code": "DIVISION_BY_ZERO", "message": "cannot divide by zero" } }Error example — unknown operation (400):
curl -s -X POST http://localhost:8080/api/v1/calculate \
-H "Content-Type: application/json" \
-d '{"operation":"modulo","operands":[5,2]}'{ "error": { "code": "UNKNOWN_OPERATION", "message": "unknown operation: \"modulo\"" } }Error example — wrong operand count (400):
curl -s -X POST http://localhost:8080/api/v1/calculate \
-H "Content-Type: application/json" \
-d '{"operation":"add","operands":[2]}'{
"error": {
"code": "INVALID_OPERAND_COUNT",
"message": "invalid operand count for operation: operation \"add\" requires 2 operands, got 1"
}
}| HTTP status | Code | Meaning |
|---|---|---|
| 400 | INVALID_JSON |
Malformed or empty request body |
| 400 | INVALID_OPERAND |
An operand is not a JSON number (string/bool/null) |
| 400 | INVALID_OPERAND_COUNT |
Wrong number of operands for the operation |
| 400 | UNKNOWN_OPERATION |
operation is not one of the supported values |
| 422 | DIVISION_BY_ZERO |
Divisor is zero |
| 422 | NEGATIVE_SQRT_OPERAND |
sqrt of a negative number |
| 422 | RESULT_OUT_OF_RANGE |
Result is +Inf/-Inf/NaN (overflow) |
Shown for POST /calculate, including the division-by-zero error branch.
sequenceDiagram
actor User
participant UI as Calculator UI
participant Hook as useCalculator hook
participant Api as calculatorApi.ts
participant Handler as Go HTTP handler
participant Calc as calculator package
User->>UI: Click "=" (e.g. 10 divided by 0)
UI->>Hook: equals()
Hook->>Api: calculate({operation, operands})
Api->>Handler: POST /api/v1/calculate
Handler->>Calc: Calculate(operation, operands)
alt Valid calculation
Calc-->>Handler: result
Handler-->>Api: 200 OK, {operation, operands, result}
Api-->>Hook: CalculateResponse
Hook-->>UI: update display and history
UI-->>User: show result
else Division by zero
Calc-->>Handler: ErrDivisionByZero
Handler-->>Api: 422, {error: DIVISION_BY_ZERO}
Api-->>Hook: throw CalculatorApiError
Hook-->>UI: set error message
UI-->>User: show auto-dismissing error toast
end
flowchart LR
User(["User"])
UC1(["Perform basic operation"])
UC2(["Perform advanced operation"])
UC3(["View error"])
UC4(["View history"])
User --> UC1
User --> UC2
User --> UC3
User --> UC4
Optional extra detail on how the two backend packages relate.
flowchart TD
subgraph cmdServer["cmd/server"]
Main["main.go"]
end
subgraph internalHandler["internal/handler"]
Router["router.go"]
Calculate["calculate.go"]
Health["health.go"]
Middleware["middleware.go"]
end
subgraph internalCalculator["internal/calculator"]
Calc["calculator.go"]
Ops["operations.go"]
Err["errors.go"]
end
Main --> Router
Router --> Middleware
Router --> Calculate
Router --> Health
Calculate --> Calc
Calc --> Ops
Calc --> Err
- Single
POST /calculateendpoint instead of one endpoint per operation. The operation is selected via a body field, so adding a new operation only means adding one entry to the backend's dispatch table and one union member on the frontend — the API contract itself never changes. - No web framework (
net/httponly). Go 1.22'shttp.ServeMuxalready supports method-aware routing ("POST /api/v1/calculate"), so a router library likechiwouldn't add anything beyond what the stdlib already provides at this scope. - Calculation logic kept out of the HTTP layer.
internal/calculatorhas no knowledge of HTTP; it returns sentinel errors (ErrDivisionByZero,ErrInvalidOperandCount, ...) thatinternal/handlermaps to status codes. This lets the arithmetic be tested with plaingo test, with no server involved, and lets the handler tests focus purely on the transport/validation concerns. - Frontend API access isolated in one service module.
src/services/calculatorApi.tsis the only file that callsfetch; every component goes through theuseCalculatorhook, which is the only consumer of that service. This is what makes the hook tests able to mock the network layer instead of dealing with real requests. - 400 vs. 422.
400is used for requests that are malformed on their face (bad JSON, wrong operand count, unknown operation — the server can't even attempt the calculation).422is used once the request is well-formed but the operation is undefined for the given input (division by zero, square root of a negative number, a result that overflows to±Inf/NaN). - Strict operand parsing. Operands are decoded as
[]interface{}first and explicitly type-asserted tofloat64, instead of decoding directly into[]float64. Go'sencoding/jsonsilently leaves anullarray element at its zero value instead of erroring, which would have made[2, null]decode as[2, 0]with no way to tell the difference from a user actually sending0. useCalculatoras a single state-owning hook rather than a reducer split across multiple files. The state machine (display, pending operation, loading, error, history) is small enough that auseState-based hook with a handful of named functions stays easy to read, and it's what the tests exercise directly withrenderHook.- Operator chaining reuses the same single-operation endpoint instead
of adding a batch/expression endpoint or evaluating expressions
client-side. Pressing an operator while one is already pending (e.g.
the
+in5 × 3 +) just triggers the samePOST /calculatethe "=" key would, immediately, using the result as the next operand — so multi-step calculations still never do arithmetic outside the backend, without touching the API contract at all. - The history panel has no backdrop by design. It's a
non-modal slide-in drawer (
HistoryPanel): opening it never blocks interacting with the calculator underneath, unlike a typical modal-with-overlay pattern. - The keypad only exposes x² (squaring), not arbitrary exponentiation,
matching a standard calculator's UI (Windows Calculator, etc). The
powerAPI operation itself is unchanged and fully general —x²just always calls it withoperands: [x, 2]. Choosing an arbitrary exponent is still possible directly against the API. - Displayed numbers use thousands separators and readable scientific
notation (
formatNumber, shared by the live display and the history list) instead of JavaScript's rawNumber#toString(), which outputs uglye+27syntax outside[1e-6, 1e21)and no grouping at all for large integers.
percentagesemantics. The original brief didn't define this one explicitly. This implementation usesoperands: [a, b]and computes "a percent of b":result = (a / 100) * b. So{operation: "percentage", operands: [50, 200]}→100.- All operands and results are
float64, so decimal input/output is supported throughout (e.g.divide(10, 4)→2.5). - A calculation result that isn't finite (
±InforNaN, e.g. frompower(10, 1000)orpower(-8, 0.5)) is treated as a422semantic error (RESULT_OUT_OF_RANGE), sinceencoding/jsoncannot serializeInf/NaNand silently returning0would be misleading. - History is capped at the last 20 calculations and persisted to
localStorage(not a backend store) — it survives a page reload but is local to the browser, which is enough for "a list of recent operations" without standing up durable server-side storage. - CORS is origin-restricted, not wildcarded (
CORS_ALLOWED_ORIGIN, defaulting to the Vite dev server's origin), since this is a two-origin local setup rather than a public API.
Backend:
cd backend
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.htmlCurrent coverage: internal/calculator 100% statements,
internal/handler 98.1% statements (the one uncovered branch is a
defensive default case for a calculator error that no current
operation can actually produce). cmd/server is thin env/wiring code
with no branching logic, so it isn't unit tested directly — it's covered
by the Docker/manual end-to-end runs instead.
Tests include table-driven success/error cases for every operation,
floating-point precision (0.1 + 0.2), overflow to ±Inf/NaN,
malformed/empty JSON bodies, non-numeric/null/boolean operands, and a
concurrent-requests test (go test -race in CI) that fires 50 parallel
requests at a real httptest.Server.
Frontend:
cd frontend
npm test # single run
npm run coverage # with a coverage report (text + HTML)Current coverage: 100% statements across all source files (services,
hooks, components, utils). Tests cover the API service (mocked fetch,
success/failure/network-error paths), the useCalculator hook in
isolation (renderHook), each UI component, and full end-to-end flows
through Calculator via both button clicks and keyboard input,
including the error-display path.
Backend:
go vet ./...
gofmt -l .
golangci-lint run ./...
gocyclo -over 10 . # cyclomatic complexity cap
gocognit -over 15 . # cognitive complexity capAll pass clean. The most complex backend functions:
| Function | Cyclomatic | Cognitive |
|---|---|---|
handler.mapCalculatorError |
6 | 1 |
calculator.Calculate |
5 | 4 |
handler.handleCalculate |
4 | 3 |
handler.parseOperands |
3 | 3 |
(Caps: 10 cyclomatic, 15 cognitive. Every other function in the backend is at or below 2/2.)
Frontend: ESLint enforces the same caps via the complexity rule
(max 10) and eslint-plugin-sonarjs's cognitive-complexity rule (max
15), run as part of npm run lint, which passes with zero warnings. The
keyboard-input handler in Calculator.tsx was refactored during
development from a flat if/else chain into a small lookup table
(SIMPLE_KEY_ACTIONS) specifically to stay under these caps.
Asymptotic complexity. Every calculator operation is O(1): add,
subtract, multiply, and percentage are single arithmetic
expressions; power and sqrt delegate to math.Pow/math.Sqrt from
the standard library rather than an iterative implementation (e.g. power
by repeated multiplication). None of the operations touch a data
structure or iterate over input, so no operation-specific Big-O
justification beyond O(1) is needed.
/calculator
/backend
/cmd/server entry point (env config, server startup)
/internal/calculator pure arithmetic logic, no HTTP knowledge
/internal/handler HTTP handlers, validation, error mapping, CORS, logging
go.mod
/frontend
/src
/components Calculator, Display, Keypad, ErrorMessage, History,
HistoryPanel, ThemeToggle
/hooks useCalculator (calculator UI state), useTheme
/services calculatorApi.ts (the only module that calls fetch)
/types shared TS types
/utils formatExpression (history display), formatNumber (shared display formatting)
package.json
openapi.yaml
docker-compose.yml
PROMPTS.md
README.md
GitHub Actions (.github/workflows/ci.yml) runs on every push and pull
request: backend (go vet, gofmt -l, go build, go test -race -coverprofile) and frontend (eslint, tsc -b, vitest run --coverage) as separate jobs.