Go · Vercel Functions
Expense Tracker
Log expenses and watch the backend compute totals and per-category breakdowns on every request.
Every total, category sum, and percentage shown on the page is computed server-side in a statically-typed Go handler — the frontend only renders the numbers it's handed. Adding or deleting an expense re-runs the aggregation and returns the fresh stats in the same response.
Live demo
embedpackage handler
import (
"encoding/json"
"math"
"net/http"
"sort"
"strconv"
"sync"
"time"
)
type Expense struct {
ID string `json:"id"`
Label string `json:"label"`
Amount float64 `json:"amount"`
Category string `json:"category"`
}
type CategoryStat struct {
Category string `json:"category"`
Amount float64 `json:"amount"`
Pct float64 `json:"pct"`
}
type Response struct {
Expenses []Expense `json:"expenses"`
Total float64 `json:"total"`
Categories []CategoryStat `json:"categories"`
}
var (
mu sync.Mutex
expenses = []Expense{
{ID: "1", Label: "Groceries", Amount: 64.2, Category: "Food"},
{ID: "2", Label: "Bus pass", Amount: 45, Category: "Transport"},
{ID: "3", Label: "Netflix", Amount: 15.99, Category: "Subscriptions"},
}
)
func round2(v float64) float64 {
return math.Round(v*100) / 100
}
func withStats() Response {
total := 0.0
byCat := map[string]float64{}
var order []string
for _, e := range expenses {
total += e.Amount
if _, seen := byCat[e.Category]; !seen {
order = append(order, e.Category)
}
byCat[e.Category] += e.Amount
}
cats := make([]CategoryStat, 0, len(order))
for _, cat := range order {
amt := byCat[cat]
pct := 0.0
if total > 0 {
pct = math.Round((amt/total)*1000) / 10
}
cats = append(cats, CategoryStat{Category: cat, Amount: round2(amt), Pct: pct})
}
sort.Slice(cats, func(i, j int) bool { return cats[i].Amount > cats[j].Amount })
return Response{Expenses: expenses, Total: round2(total), Categories: cats}
}
func writeJSON(w http.ResponseWriter, status int, payload interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(payload)
}
// Handler is the Vercel Go serverless entrypoint for /api/expenses.
func Handler(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, withStats())
case http.MethodPost:
var body struct {
Label string `json:"label"`
Amount string `json:"amount"`
Category string `json:"category"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
amount, err := strconv.ParseFloat(body.Amount, 64)
category := body.Category
if category == "" {
category = "Other"
}
if body.Label == "" || err != nil || amount <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "label and a positive amount are required"})
return
}
expenses = append(expenses, Expense{
ID: strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10),
Label: body.Label,
Amount: amount,
Category: category,
})
writeJSON(w, http.StatusCreated, withStats())
case http.MethodDelete:
id := r.URL.Query().Get("id")
filtered := expenses[:0]
for _, e := range expenses {
if e.ID != id {
filtered = append(filtered, e)
}
}
expenses = filtered
writeJSON(w, http.StatusOK, withStats())
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}
const form = document.getElementById('form');
const errorBox = document.getElementById('error');
function render(data) {
document.getElementById('total').innerHTML = `$${data.total.toFixed(2)}<span>Total spent</span>`;
const cats = document.getElementById('categories');
cats.innerHTML = data.categories.map((c) => `
<div class="bar-row">
<div class="bar-label"><span>${c.category}</span><span>$${c.amount.toFixed(2)} · ${c.pct}%</span></div>
<div class="bar-track"><div class="bar-fill" style="width:${c.pct}%"></div></div>
</div>
`).join('') || '<p style="color:var(--muted)">No expenses yet.</p>';
const list = document.getElementById('list');
list.innerHTML = data.expenses.slice().reverse().map((e) => `
<div class="item">
<span class="label">${e.label}</span>
<span class="cat">${e.category}</span>
<span class="amt">$${e.amount.toFixed(2)}</span>
<button class="icon" data-id="${e.id}" title="Delete">✕</button>
</div>
`).join('') || '<p class="empty" style="color:var(--muted)">No expenses yet.</p>';
list.querySelectorAll('button.icon').forEach((btn) => {
btn.addEventListener('click', () => removeExpense(btn.dataset.id));
});
}
async function load() {
const res = await fetch('/api/expenses');
render(await res.json());
}
async function removeExpense(id) {
const res = await fetch(`/api/expenses?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
render(await res.json());
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorBox.textContent = '';
const label = document.getElementById('label').value.trim();
const amount = document.getElementById('amount').value;
const category = document.getElementById('category').value.trim() || 'Other';
const res = await fetch('/api/expenses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, amount, category }),
});
const data = await res.json();
if (!res.ok) {
errorBox.textContent = data.error || 'Something went wrong.';
return;
}
form.reset();
document.getElementById('category').value = 'Other';
render(data);
});
load();