From 796660c3cba4491121bf0f4c751aee369ebf5ae5 Mon Sep 17 00:00:00 2001 From: "Dejan R." Date: Thu, 4 Dec 2025 19:13:18 +0100 Subject: [PATCH] add countdown -> and API for phone accessing --- main.go | 358 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 329 insertions(+), 29 deletions(-) diff --git a/main.go b/main.go index 34d831f..9d081ea 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,4 @@ +// main.go package main import ( @@ -7,18 +8,22 @@ import ( "log" "net/http" "os" + "path" "strconv" + "strings" "time" _ "github.com/mattn/go-sqlite3" ) var ( - db *sql.DB - indexTmpl *template.Template + db *sql.DB + indexTmpl *template.Template listenAddr = ":8080" ) +// --- Types --- + // A single set row for display type SetRow struct { ID int @@ -51,6 +56,25 @@ type Workout struct { Template string } +// DTOs for API +type WorkoutDTO struct { + ID int `json:"id"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + Template string `json:"template"` + SetCount int `json:"set_count"` + Volume float64 `json:"volume"` +} + +type SetDTO struct { + ID int `json:"id"` + WorkoutID int `json:"workout_id"` + Exercise string `json:"exercise"` + Reps int `json:"reps"` + Weight float64 `json:"weight"` + StartedAt time.Time `json:"started_at"` +} + func main() { var err error @@ -61,6 +85,13 @@ func main() { } log.Printf("Using database file: %s", dbPath) + // Ensure directory exists if path includes folder + if dir := path.Dir(dbPath); dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + log.Fatalf("failed to create db directory %s: %v", dir, err) + } + } + // Open SQLite DB db, err = sql.Open("sqlite3", dbPath) if err != nil { @@ -78,17 +109,22 @@ func main() { "eq": func(a, b interface{}) bool { return a == b }, }).Parse(indexHTML)) - // Routes + // Routes (HTML) http.HandleFunc("/", handleIndex) http.HandleFunc("/start", handleStartWorkout) http.HandleFunc("/finish", handleFinishWorkout) http.HandleFunc("/add-set", handleAddSet) - // APIs + // Existing APIs http.HandleFunc("/api/rest-times", handleRestTimes) http.HandleFunc("/api/volume", handleVolumeStats) http.HandleFunc("/api/last-set", handleLastSet) + // New JSON APIs for mobile app + http.HandleFunc("/api/workouts", handleAPIWorkouts) + http.HandleFunc("/api/workouts/", handleAPIWorkoutDetail) // /api/workouts/{id} + http.HandleFunc("/api/sets", handleAPIAddSetJSON) + log.Printf("Listening on %s ...", listenAddr) if err := http.ListenAndServe(listenAddr, nil); err != nil { log.Fatal(err) @@ -124,7 +160,7 @@ CREATE TABLE IF NOT EXISTS sets ( return nil } -// --- Handlers --- +// --- HTML Handlers --- func handleIndex(w http.ResponseWriter, r *http.Request) { // Current / latest workout @@ -170,13 +206,13 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { } data := struct { - HasWorkout bool - WorkoutID int - WorkoutActive bool - WorkoutStart *time.Time - WorkoutEnd *time.Time + HasWorkout bool + WorkoutID int + WorkoutActive bool + WorkoutStart *time.Time + WorkoutEnd *time.Time WorkoutTemplate string - Sets []SetRow + Sets []SetRow From string To string @@ -458,7 +494,25 @@ func handleVolumeStats(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(resp) } -// --- API: last set for exercise (for autofill) --- +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 +} + +// --- API: last set for exercise (autofill) --- func handleLastSet(w http.ResponseWriter, r *http.Request) { ex := r.URL.Query().Get("exercise") @@ -497,22 +551,201 @@ func handleLastSet(w http.ResponseWriter, r *http.Request) { _ = 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) +// --- JSON APIs for mobile app --- - 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 +// /api/workouts +// GET -> list latest workouts +// POST -> start new workout { "template": "A" } +func handleAPIWorkouts(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + workouts, err := getWorkoutSummaries(nil, nil) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + // convert to DTOs + var out []WorkoutDTO + for _, wsum := range workouts { + out = append(out, WorkoutDTO{ + ID: wsum.ID, + StartedAt: wsum.StartedAt, + EndedAt: wsum.EndedAt, + Template: wsum.Template, + SetCount: wsum.SetCount, + Volume: wsum.Volume, + }) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) + case http.MethodPost: + var req struct { + Template string `json:"template"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + now := time.Now().UTC().Format(time.RFC3339) + res, err := db.Exec(`INSERT INTO workouts (started_at, template) VALUES (?, ?)`, now, req.Template) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + id64, _ := res.LastInsertId() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(struct { + ID int64 `json:"id"` + }{ID: id64}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } - return vol, nil +} + +// /api/workouts/{id} +func handleAPIWorkoutDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + // path: /api/workouts/{id} + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) != 3 { + http.Error(w, "not found", http.StatusNotFound) + return + } + idStr := parts[2] + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + // get workout + row := db.QueryRow(`SELECT id, started_at, ended_at, template FROM workouts WHERE id = ?`, id) + var wid int + var startedStr string + var endedStr sql.NullString + var templateStr sql.NullString + if err := row.Scan(&wid, &startedStr, &endedStr, &templateStr); err != nil { + if err == sql.ErrNoRows { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "db error", http.StatusInternalServerError) + return + } + started, err := time.Parse(time.RFC3339, startedStr) + if err != nil { + http.Error(w, "time parse error", http.StatusInternalServerError) + return + } + 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 + } + + // sets + setsRows, err := getSetsForWorkout(id) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + var sets []SetDTO + for _, s := range setsRows { + sets = append(sets, SetDTO{ + ID: s.ID, + WorkoutID: id, + Exercise: s.Exercise, + Reps: s.Reps, + Weight: s.Weight, + StartedAt: s.StartedAt, + }) + } + + // volume + var volume float64 + for _, s := range setsRows { + volume += float64(s.Reps) * s.Weight + } + + resp := struct { + Workout WorkoutDTO `json:"workout"` + Sets []SetDTO `json:"sets"` + }{ + Workout: WorkoutDTO{ + ID: wid, + StartedAt: started, + EndedAt: ended, + Template: tmpl, + SetCount: len(setsRows), + Volume: volume, + }, + Sets: sets, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// /api/sets (POST JSON) -> create set +func handleAPIAddSetJSON(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + WorkoutID int `json:"workout_id"` + Exercise string `json:"exercise"` + Reps int `json:"reps"` + Weight float64 `json:"weight"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + // choose workout id: if 0 -> latest + var workoutID int + if req.WorkoutID == 0 { + latest, err := getLatestWorkout() + if err != nil || latest == nil { + http.Error(w, "no active workout", http.StatusBadRequest) + return + } + workoutID = latest.ID + } else { + workoutID = req.WorkoutID + } + + if req.Exercise == "" || req.Reps <= 0 || req.Weight < 0 { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + now := time.Now().UTC().Format(time.RFC3339) + res, err := db.Exec( + `INSERT INTO sets (workout_id, exercise, reps, weight, started_at) + VALUES (?, ?, ?, ?, ?)`, + workoutID, req.Exercise, req.Reps, req.Weight, now, + ) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + id64, _ := res.LastInsertId() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(struct { + ID int64 `json:"id"` + }{ID: id64}) } // --- Helpers & summaries --- @@ -720,7 +953,7 @@ func formatDuration(d time.Duration) string { return strconv.Itoa(s) + "s" } -// --- HTML template (single page) --- +// --- HTML template (includes rest timer card) --- const indexHTML = ` @@ -831,11 +1064,16 @@ const indexHTML = ` .tag:hover { background:#374151; } + .timer-display { + font-size: 1.5rem; + font-weight: 600; + margin-top: 0.5rem; + }

Minimal Fitness Tracker

-

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

+

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

@@ -932,7 +1170,7 @@ const indexHTML = ` -

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

+

Time you press “Save set” = set timestamp. Rest between sets is automatic.

{{else}}

Start a workout first.

@@ -988,6 +1226,17 @@ const indexHTML = `

+ +
+

Rest timer (with notification)

+ + +
+

When the timer ends, you’ll get a browser notification (if allowed).

+
@@ -1053,6 +1302,8 @@ const indexHTML = `