package main import ( "database/sql" "encoding/json" "html/template" "log" "net/http" "strconv" "time" _ "github.com/mattn/go-sqlite3" ) var ( db *sql.DB indexTmpl *template.Template listenAddr = ":8080" databaseFile = "fitness.db" ) type SetRow struct { ID int Exercise string Reps int Weight float64 StartedAt time.Time } func main() { var err error // Open SQLite DB db, err = sql.Open("sqlite3", databaseFile) if err != nil { log.Fatal("open db:", err) } defer db.Close() if err := initDB(); err != nil { log.Fatal("init db:", err) } // Parse template with helper funcs indexTmpl = template.Must(template.New("index").Funcs(template.FuncMap{ "add": func(a, b int) int { return a + b }, }).Parse(indexHTML)) // Routes http.HandleFunc("/", handleIndex) http.HandleFunc("/start", handleStartWorkout) http.HandleFunc("/add-set", handleAddSet) // APIs http.HandleFunc("/api/rest-times", handleRestTimes) http.HandleFunc("/api/volume", handleVolumeStats) log.Printf("Listening on %s ...", listenAddr) if err := http.ListenAndServe(listenAddr, nil); err != nil { log.Fatal(err) } } func initDB() error { // Very simple schema schema := ` CREATE TABLE IF NOT EXISTS workouts ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sets ( id INTEGER PRIMARY KEY AUTOINCREMENT, workout_id INTEGER NOT NULL, exercise TEXT NOT NULL, reps INTEGER NOT NULL, weight REAL NOT NULL, started_at TEXT NOT NULL, FOREIGN KEY(workout_id) REFERENCES workouts(id) ); ` _, err := db.Exec(schema) return err } // --- Handlers --- func handleIndex(w http.ResponseWriter, r *http.Request) { latestID, err := getLatestWorkoutID() if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } var sets []SetRow if latestID != 0 { sets, err = getSetsForWorkout(latestID) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } } data := struct { HasWorkout bool WorkoutID int Sets []SetRow }{ HasWorkout: latestID != 0, WorkoutID: latestID, Sets: sets, } if err := indexTmpl.Execute(w, data); err != nil { log.Println("template error:", err) } } func handleStartWorkout(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Redirect(w, r, "/", http.StatusSeeOther) return } now := time.Now().UTC().Format(time.RFC3339) _, err := db.Exec(`INSERT INTO workouts (started_at) VALUES (?)`, now) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } http.Redirect(w, r, "/", http.StatusSeeOther) } func handleAddSet(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Redirect(w, r, "/", http.StatusSeeOther) return } // Always attach to latest workout workoutID, err := getLatestWorkoutID() if err != nil || workoutID == 0 { http.Error(w, "no active workout", http.StatusBadRequest) return } // NEW: exercise from dropdown + optional custom selected := r.FormValue("exercise_select") exerciseCustom := r.FormValue("exercise_custom") exercise := selected if selected == "custom" { exercise = exerciseCustom } repsStr := r.FormValue("reps") weightStr := r.FormValue("weight") if exercise == "" || repsStr == "" || weightStr == "" { http.Error(w, "missing fields", http.StatusBadRequest) return } reps, err := strconv.Atoi(repsStr) if err != nil { http.Error(w, "invalid reps", http.StatusBadRequest) return } weight, err := strconv.ParseFloat(weightStr, 64) if err != nil { http.Error(w, "invalid weight", http.StatusBadRequest) return } now := time.Now().UTC().Format(time.RFC3339) _, err = db.Exec( `INSERT INTO sets (workout_id, exercise, reps, weight, started_at) VALUES (?, ?, ?, ?, ?)`, workoutID, exercise, reps, weight, now, ) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } http.Redirect(w, r, "/", http.StatusSeeOther) } // --- API: rest times between sets (latest workout) --- func handleRestTimes(w http.ResponseWriter, r *http.Request) { workoutID, err := getLatestWorkoutID() if err != nil || workoutID == 0 { http.Error(w, "no workout", http.StatusBadRequest) return } rows, err := db.Query( `SELECT id, started_at FROM sets WHERE workout_id = ? ORDER BY started_at`, workoutID, ) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } defer rows.Close() type simpleSet struct { ID int StartedAt time.Time } var sets []simpleSet for rows.Next() { var id int var startedStr string if err := rows.Scan(&id, &startedStr); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } t, err := time.Parse(time.RFC3339, startedStr) if err != nil { http.Error(w, "time parse error", http.StatusInternalServerError) return } sets = append(sets, simpleSet{ID: id, StartedAt: t}) } if err := rows.Err(); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } // Compute rest times (seconds) between sets var labels []string var data []float64 for i := 1; i < len(sets); i++ { delta := sets[i].StartedAt.Sub(sets[i-1].StartedAt).Seconds() if delta < 0 { delta = 0 } labels = append(labels, "Set "+strconv.Itoa(i+1)) data = append(data, delta) } resp := struct { Labels []string `json:"labels"` Data []float64 `json:"data"` }{ Labels: labels, Data: data, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } // --- API: volume stats (day / week / month) --- func handleVolumeStats(w http.ResponseWriter, r *http.Request) { rangeParam := r.URL.Query().Get("range") if rangeParam == "" { rangeParam = "day" } now := time.Now() var curStart, curEnd time.Time switch rangeParam { case "week": // Start of ISO week (Monday) weekday := int(now.Weekday()) if weekday == 0 { weekday = 7 // Sunday } curStart = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()). AddDate(0, 0, -(weekday - 1)) curEnd = curStart.AddDate(0, 0, 7) case "month": curStart = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) curEnd = curStart.AddDate(0, 1, 0) default: // "day" curStart = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) curEnd = curStart.AddDate(0, 0, 1) rangeParam = "day" } // Previous period of same length period := curEnd.Sub(curStart) prevEnd := curStart prevStart := prevEnd.Add(-period) currentVol, err := queryVolume(curStart, curEnd) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } prevVol, err := queryVolume(prevStart, prevEnd) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } percentChange := 0.0 hasPrevious := prevVol > 0 if hasPrevious { percentChange = (currentVol - prevVol) / prevVol * 100.0 } resp := struct { Range string `json:"range"` CurrentVolume float64 `json:"current_volume"` PreviousVolume float64 `json:"previous_volume"` PercentChange float64 `json:"percent_change"` HasPrevious bool `json:"has_previous"` CurrentLabel string `json:"current_label"` PreviousLabel string `json:"previous_label"` }{ Range: rangeParam, CurrentVolume: currentVol, PreviousVolume: prevVol, PercentChange: percentChange, HasPrevious: hasPrevious, CurrentLabel: curStart.Format("2006-01-02"), PreviousLabel: prevStart.Format("2006-01-02"), } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } func queryVolume(start, end time.Time) (float64, error) { // Sum of (reps * weight) for sets whose started_at is in [start, end) startStr := start.UTC().Format(time.RFC3339) endStr := end.UTC().Format(time.RFC3339) row := db.QueryRow(` SELECT COALESCE(SUM(reps * weight), 0) FROM sets WHERE started_at >= ? AND started_at < ? `, startStr, endStr) var vol float64 if err := row.Scan(&vol); err != nil { return 0, err } return vol, nil } // --- Helpers --- func getLatestWorkoutID() (int, error) { row := db.QueryRow(`SELECT id FROM workouts ORDER BY id DESC LIMIT 1`) var id int err := row.Scan(&id) if err == sql.ErrNoRows { return 0, nil } return id, err } func getSetsForWorkout(workoutID int) ([]SetRow, error) { rows, err := db.Query( `SELECT id, exercise, reps, weight, started_at FROM sets WHERE workout_id = ? ORDER BY started_at`, workoutID, ) if err != nil { return nil, err } defer rows.Close() var out []SetRow for rows.Next() { var s SetRow var startedStr string if err := rows.Scan(&s.ID, &s.Exercise, &s.Reps, &s.Weight, &startedStr); err != nil { return nil, err } t, err := time.Parse(time.RFC3339, startedStr) if err != nil { return nil, err } s.StartedAt = t out = append(out, s) } return out, rows.Err() } // --- HTML template (single page) --- const indexHTML = ` Minimal Fitness Tracker

Minimal Fitness Tracker

Simple self-hosted Go app – 5×5 friendly: pick standard lifts, track volume and rest.

Workout

{{if .HasWorkout}}

Current workout ID: {{.WorkoutID}}

{{else}}

No workout yet.

{{end}}

Add set

{{if .HasWorkout}}
{{else}}

Start a workout first.

{{end}}

Sets in latest workout

{{if .Sets}} {{range $i, $s := .Sets}} {{end}}
# Exercise Reps Weight Time (UTC)
{{add $i 1}} {{$s.Exercise}} {{$s.Reps}} {{$s.Weight}} {{$s.StartedAt.Format "15:04:05"}}
{{else}}

No sets logged yet.

{{end}}

Rest time between sets (latest workout)

Training volume (Σ weight × reps)

`