From 72910db36e0dd9902cdae631da3618446e3beef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dejan=20Ro=C5=BEi=C4=8D?= Date: Thu, 4 Dec 2025 13:51:08 +0100 Subject: [PATCH] added first code --- go.mod | 5 + go.sum | 2 + main.go | 696 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 703 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e0f6daa --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module fitnestracker + +go 1.23.0 + +require github.com/mattn/go-sqlite3 v1.14.32 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..66f7516 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/main.go b/main.go new file mode 100644 index 0000000..fc38c30 --- /dev/null +++ b/main.go @@ -0,0 +1,696 @@ +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 + } + + exercise := r.FormValue("exercise") + 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 – log sets, see rest times and training volume.

+ +
+
+
+

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}} + +
#ExerciseRepsWeightTime (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)

+ + + +

+
+
+
+ + + + + + +`