diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5919d2a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM debian:bookworm-slim + +# (optional) install ca-certificates if needed +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /app/fitness-app /app/fitness-app + +# External data dir for sqlite db +RUN mkdir -p /data + +ENV DATABASE_URL=/data/fitness.db + +# DO NOT switch user -> run as root +EXPOSE 8080 +VOLUME ["/data"] + +CMD ["/app/fitness-app"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..65b025a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3.9" + +services: + fitness: + build: . + container_name: fitness-tracker + restart: unless-stopped + environment: + # DB file inside container + - DATABASE_URL=/data/fitness.db + volumes: + # Host folder -> container /data + # On host, your db will be: ./fitness-data/fitness.db + - ./fitness-data:/data + ports: + # For quick testing; in production you'd put Traefik in front instead + - "8080:8080" diff --git a/main.go b/main.go index 476a20d..34d831f 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "html/template" "log" "net/http" + "os" "strconv" "time" @@ -13,12 +14,12 @@ import ( ) var ( - db *sql.DB - indexTmpl *template.Template - listenAddr = ":8080" - databaseFile = "fitness.db" + db *sql.DB + indexTmpl *template.Template + listenAddr = ":8080" ) +// A single set row for display type SetRow struct { ID int Exercise string @@ -27,11 +28,41 @@ type SetRow struct { 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", databaseFile) + db, err = sql.Open("sqlite3", dbPath) if err != nil { log.Fatal("open db:", err) } @@ -44,16 +75,19 @@ func main() { // 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 { @@ -62,7 +96,7 @@ func main() { } func initDB() error { - // Very simple schema + // Migration-friendly: create basic tables, then ALTER for new columns. schema := ` CREATE TABLE IF NOT EXISTS workouts ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -79,36 +113,106 @@ CREATE TABLE IF NOT EXISTS sets ( FOREIGN KEY(workout_id) REFERENCES workouts(id) ); ` - _, err := db.Exec(schema) - return err + 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) { - latestID, err := getLatestWorkoutID() + // Current / latest workout + latest, err := getLatestWorkout() if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } var sets []SetRow - if latestID != 0 { - sets, err = getSetsForWorkout(latestID) + 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 - Sets []SetRow + HasWorkout bool + WorkoutID int + WorkoutActive bool + WorkoutStart *time.Time + WorkoutEnd *time.Time + WorkoutTemplate string + Sets []SetRow + + From string + To string + + Workouts []WorkoutSummary }{ - HasWorkout: latestID != 0, - WorkoutID: latestID, - Sets: sets, + 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 { @@ -122,8 +226,9 @@ func handleStartWorkout(w http.ResponseWriter, r *http.Request) { return } now := time.Now().UTC().Format(time.RFC3339) + templateVal := r.FormValue("template") // "" / "A" / "B" - _, err := db.Exec(`INSERT INTO workouts (started_at) VALUES (?)`, now) + _, err := db.Exec(`INSERT INTO workouts (started_at, template) VALUES (?, ?)`, now, templateVal) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return @@ -132,6 +237,28 @@ func handleStartWorkout(w http.ResponseWriter, r *http.Request) { 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) @@ -139,13 +266,13 @@ func handleAddSet(w http.ResponseWriter, r *http.Request) { } // Always attach to latest workout - workoutID, err := getLatestWorkoutID() - if err != nil || workoutID == 0 { + latest, err := getLatestWorkout() + if err != nil || latest == nil { http.Error(w, "no active workout", http.StatusBadRequest) return } - // NEW: exercise from dropdown + optional custom + // exercise from dropdown + optional custom selected := r.FormValue("exercise_select") exerciseCustom := r.FormValue("exercise_custom") @@ -179,7 +306,7 @@ func handleAddSet(w http.ResponseWriter, r *http.Request) { _, err = db.Exec( `INSERT INTO sets (workout_id, exercise, reps, weight, started_at) VALUES (?, ?, ?, ?, ?)`, - workoutID, exercise, reps, weight, now, + latest.ID, exercise, reps, weight, now, ) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) @@ -192,15 +319,15 @@ func handleAddSet(w http.ResponseWriter, r *http.Request) { // --- API: rest times between sets (latest workout) --- func handleRestTimes(w http.ResponseWriter, r *http.Request) { - workoutID, err := getLatestWorkoutID() - if err != nil || workoutID == 0 { + 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`, - workoutID, + latest.ID, ) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) @@ -276,7 +403,7 @@ func handleVolumeStats(w http.ResponseWriter, r *http.Request) { weekday = 7 // Sunday } curStart = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()). - AddDate(0, 0, -(weekday - 1)) + 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()) @@ -331,6 +458,45 @@ func handleVolumeStats(w http.ResponseWriter, r *http.Request) { _ = 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) @@ -349,16 +515,38 @@ func queryVolume(start, end time.Time) (float64, error) { return vol, nil } -// --- Helpers --- +// --- Helpers & summaries --- -func getLatestWorkoutID() (int, error) { - row := db.QueryRow(`SELECT id FROM workouts ORDER BY id DESC LIMIT 1`) +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 - err := row.Scan(&id) + var startedStr string + var endedStr sql.NullString + var templateStr sql.NullString + + err := row.Scan(&id, &startedStr, &endedStr, &templateStr) if err == sql.ErrNoRows { - return 0, nil + return nil, nil } - return id, err + 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) { @@ -391,6 +579,147 @@ func getSetsForWorkout(workoutID int) ([]SetRow, error) { 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 = ` @@ -403,7 +732,7 @@ const indexHTML = `
Simple self-hosted Go app – 5×5 friendly: pick standard lifts, track volume and rest.
+Self-hosted 5×5 log – A/B templates, volume, rest, and history.
Current workout ID: {{.WorkoutID}}
+
+ 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}}
+
+
No workout yet.
{{end}} - + + {{if .WorkoutActive}} + + {{end}}+ Today: Squat + Bench + Row +
+ {{else if eq .WorkoutTemplate "B"}} ++ Today: Squat + OHP + Deadlift + Chin-up +
+ {{end}} + {{else}}Start a workout first.
@@ -599,6 +991,63 @@ const indexHTML = `| 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}} +