package main import ( "database/sql" "encoding/json" "html/template" "log" "net/http" "os" "strconv" "time" _ "github.com/mattn/go-sqlite3" ) var ( db *sql.DB indexTmpl *template.Template listenAddr = ":8080" ) // A single set row for display type SetRow struct { ID int Exercise string Reps int Weight float64 StartedAt time.Time } // Workout summary for history view type WorkoutSummary struct { ID int StartedAt time.Time EndedAt *time.Time Duration time.Duration SetCount int Volume float64 TotalRest time.Duration HasEnded bool DurationHuman string RestHuman string Template string } // Internal workout structure type Workout struct { ID int StartedAt time.Time EndedAt *time.Time Template string } func main() { var err error // --- DB path from env: DATABASE_URL (fallback: fitness.db) --- dbPath := os.Getenv("DATABASE_URL") if dbPath == "" { dbPath = "fitness.db" } log.Printf("Using database file: %s", dbPath) // Open SQLite DB db, err = sql.Open("sqlite3", dbPath) 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 }, "eq": func(a, b interface{}) bool { return a == b }, }).Parse(indexHTML)) // Routes http.HandleFunc("/", handleIndex) http.HandleFunc("/start", handleStartWorkout) http.HandleFunc("/finish", handleFinishWorkout) http.HandleFunc("/add-set", handleAddSet) // APIs http.HandleFunc("/api/rest-times", handleRestTimes) http.HandleFunc("/api/volume", handleVolumeStats) http.HandleFunc("/api/last-set", handleLastSet) log.Printf("Listening on %s ...", listenAddr) if err := http.ListenAndServe(listenAddr, nil); err != nil { log.Fatal(err) } } func initDB() error { // Migration-friendly: create basic tables, then ALTER for new columns. 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) ); ` if _, err := db.Exec(schema); err != nil { return err } // Add new columns if they don't exist (ignore errors if already there). _, _ = db.Exec(`ALTER TABLE workouts ADD COLUMN ended_at TEXT`) _, _ = db.Exec(`ALTER TABLE workouts ADD COLUMN template TEXT`) return nil } // --- Handlers --- func handleIndex(w http.ResponseWriter, r *http.Request) { // Current / latest workout latest, err := getLatestWorkout() if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } var sets []SetRow if latest != nil { sets, err = getSetsForWorkout(latest.ID) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } } // Date filters for history fromStr := r.URL.Query().Get("from") toStr := r.URL.Query().Get("to") var from, to *time.Time if fromStr != "" { t, err := time.Parse("2006-01-02", fromStr) if err == nil { t0 := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local) from = &t0 } } if toStr != "" { t, err := time.Parse("2006-01-02", toStr) if err == nil { t1 := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 1) to = &t1 } } workouts, err := getWorkoutSummaries(from, to) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } data := struct { HasWorkout bool WorkoutID int WorkoutActive bool WorkoutStart *time.Time WorkoutEnd *time.Time WorkoutTemplate string Sets []SetRow From string To string Workouts []WorkoutSummary }{ HasWorkout: latest != nil, WorkoutID: func() int { if latest != nil { return latest.ID } return 0 }(), WorkoutActive: latest != nil && latest.EndedAt == nil, WorkoutStart: func() *time.Time { if latest != nil { return &latest.StartedAt } return nil }(), WorkoutEnd: func() *time.Time { if latest != nil { return latest.EndedAt } return nil }(), WorkoutTemplate: func() string { if latest != nil { return latest.Template } return "" }(), Sets: sets, From: fromStr, To: toStr, Workouts: workouts, } 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) templateVal := r.FormValue("template") // "" / "A" / "B" _, err := db.Exec(`INSERT INTO workouts (started_at, template) VALUES (?, ?)`, now, templateVal) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } http.Redirect(w, r, "/", http.StatusSeeOther) } func handleFinishWorkout(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Redirect(w, r, "/", http.StatusSeeOther) return } // Find latest workout that is not finished row := db.QueryRow(`SELECT id FROM workouts WHERE ended_at IS NULL ORDER BY id DESC LIMIT 1`) var id int if err := row.Scan(&id); err != nil { // no active workout; just return http.Redirect(w, r, "/", http.StatusSeeOther) return } now := time.Now().UTC().Format(time.RFC3339) _, err := db.Exec(`UPDATE workouts SET ended_at = ? WHERE id = ?`, now, id) 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 latest, err := getLatestWorkout() if err != nil || latest == nil { http.Error(w, "no active workout", http.StatusBadRequest) return } // 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 (?, ?, ?, ?, ?)`, latest.ID, 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) { latest, err := getLatestWorkout() if err != nil || latest == nil { 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`, latest.ID, ) 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) } // --- API: last set for exercise (for autofill) --- func handleLastSet(w http.ResponseWriter, r *http.Request) { ex := r.URL.Query().Get("exercise") if ex == "" { http.Error(w, "missing exercise", http.StatusBadRequest) return } row := db.QueryRow(`SELECT reps, weight FROM sets WHERE exercise = ? ORDER BY started_at DESC LIMIT 1`, ex) var reps int var weight float64 err := row.Scan(&reps, &weight) if err == sql.ErrNoRows { resp := struct { Found bool `json:"found"` }{Found: false} w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) return } if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } resp := struct { Found bool `json:"found"` Reps int `json:"reps"` Weight float64 `json:"weight"` }{ Found: true, Reps: reps, Weight: weight, } 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 & summaries --- func getLatestWorkout() (*Workout, error) { row := db.QueryRow(`SELECT id, started_at, ended_at, template FROM workouts ORDER BY id DESC LIMIT 1`) var id int var startedStr string var endedStr sql.NullString var templateStr sql.NullString err := row.Scan(&id, &startedStr, &endedStr, &templateStr) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } started, err := time.Parse(time.RFC3339, startedStr) if err != nil { return nil, err } var ended *time.Time if endedStr.Valid && endedStr.String != "" { t, err := time.Parse(time.RFC3339, endedStr.String) if err == nil { ended = &t } } tmpl := "" if templateStr.Valid { tmpl = templateStr.String } return &Workout{ID: id, StartedAt: started, EndedAt: ended, Template: tmpl}, nil } 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() } // Get workout summaries for history, optional date filter func getWorkoutSummaries(from, to *time.Time) ([]WorkoutSummary, error) { query := `SELECT id, started_at, ended_at, template FROM workouts` var args []interface{} if from != nil || to != nil { query += " WHERE" first := true if from != nil { query += " started_at >= ?" args = append(args, from.UTC().Format(time.RFC3339)) first = false } if to != nil { if !first { query += " AND" } query += " started_at < ?" args = append(args, to.UTC().Format(time.RFC3339)) } } query += " ORDER BY started_at DESC LIMIT 50" rows, err := db.Query(query, args...) if err != nil { return nil, err } defer rows.Close() var res []WorkoutSummary for rows.Next() { var id int var startedStr string var endedStr sql.NullString var templateStr sql.NullString if err := rows.Scan(&id, &startedStr, &endedStr, &templateStr); err != nil { return nil, err } started, err := time.Parse(time.RFC3339, startedStr) if err != nil { return nil, err } var ended *time.Time hasEnded := false if endedStr.Valid && endedStr.String != "" { t, err := time.Parse(time.RFC3339, endedStr.String) if err == nil { ended = &t hasEnded = true } } tmpl := "" if templateStr.Valid { tmpl = templateStr.String } // Stats: sets, volume, total rest rows2, err := db.Query(`SELECT reps, weight, started_at FROM sets WHERE workout_id = ? ORDER BY started_at`, id) if err != nil { return nil, err } type setData struct { Reps int Weight float64 StartedAt time.Time } var sd []setData for rows2.Next() { var r int var w float64 var stStr string if err := rows2.Scan(&r, &w, &stStr); err != nil { rows2.Close() return nil, err } t, err := time.Parse(time.RFC3339, stStr) if err != nil { rows2.Close() return nil, err } sd = append(sd, setData{Reps: r, Weight: w, StartedAt: t}) } rows2.Close() setCount := len(sd) var volume float64 var rest time.Duration for i, s := range sd { volume += float64(s.Reps) * s.Weight if i > 0 { d := s.StartedAt.Sub(sd[i-1].StartedAt) if d > 0 { rest += d } } } var duration time.Duration if ended != nil { duration = ended.Sub(started) } else { duration = 0 } res = append(res, WorkoutSummary{ ID: id, StartedAt: started, EndedAt: ended, Duration: duration, SetCount: setCount, Volume: volume, TotalRest: rest, HasEnded: hasEnded, DurationHuman: formatDuration(duration), RestHuman: formatDuration(rest), Template: tmpl, }) } return res, rows.Err() } // simple duration formatter: 1h 23m, 5m 10s, etc. func formatDuration(d time.Duration) string { if d <= 0 { return "-" } sec := int(d.Seconds()) h := sec / 3600 sec = sec % 3600 m := sec / 60 s := sec % 60 if h > 0 { return strconv.Itoa(h) + "h " + strconv.Itoa(m) + "m" } if m > 0 { return strconv.Itoa(m) + "m " + strconv.Itoa(s) + "s" } return strconv.Itoa(s) + "s" } // --- HTML template (single page) --- const indexHTML = ` Minimal Fitness Tracker

Minimal Fitness Tracker

Self-hosted 5×5 log – A/B templates, volume, rest, and history.

Workout control

{{if .HasWorkout}}

Current workout ID: {{.WorkoutID}}
Started: {{if .WorkoutStart}}{{.WorkoutStart.Format "2006-01-02 15:04"}} UTC{{else}}-{{end}}
Ended: {{if .WorkoutEnd}}{{.WorkoutEnd.Format "2006-01-02 15:04"}} UTC{{else}}(active){{end}}
Template: {{if eq .WorkoutTemplate "A"}} A: Squat / Bench / Row {{else if eq .WorkoutTemplate "B"}} B: Squat / OHP / Deadlift / Chin-up {{else}} None {{end}}

{{else}}

No workout yet.

{{end}}
{{if .WorkoutActive}}
{{end}}

Add set

{{if .HasWorkout}} {{if eq .WorkoutTemplate "A"}}

Today: Squat Bench Row

{{else if eq .WorkoutTemplate "B"}}

Today: Squat OHP Deadlift Chin-up

{{end}}

Tip: the time you press “Save set” is used as the set time. Rest between sets is calculated automatically.

{{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)

Workout history & filter

{{if .Workouts}} {{range .Workouts}} {{end}}
ID Date Start End Dur. Sets Volume Rest (approx.) Tpl
{{.ID}} {{.StartedAt.Format "2006-01-02"}} {{.StartedAt.Format "15:04"}} {{if .HasEnded}}{{.EndedAt.Format "15:04"}}{{else}}-{{end}} {{.DurationHuman}} {{.SetCount}} {{printf "%.0f" .Volume}} {{.RestHuman}} {{.Template}}

Rest time is estimated from time between sets (button presses).

{{else}}

No workouts logged yet.

{{end}}
`