Fitnes-tracker/main.go

1533 lines
38 KiB
Go
Raw Normal View History

// main.go
2025-12-04 12:51:08 +00:00
package main
import (
"database/sql"
"encoding/json"
"html/template"
"log"
"net/http"
2025-12-04 17:49:19 +00:00
"os"
"path"
2025-12-04 12:51:08 +00:00
"strconv"
"strings"
2025-12-04 12:51:08 +00:00
"time"
_ "github.com/mattn/go-sqlite3"
)
var (
db *sql.DB
indexTmpl *template.Template
2025-12-04 17:49:19 +00:00
listenAddr = ":8080"
2025-12-04 12:51:08 +00:00
)
// --- Types ---
2025-12-04 17:49:19 +00:00
// A single set row for display
2025-12-04 12:51:08 +00:00
type SetRow struct {
ID int
Exercise string
Reps int
Weight float64
StartedAt time.Time
}
2025-12-04 17:49:19 +00:00
// 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
}
// 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"`
}
2025-12-04 12:51:08 +00:00
func main() {
var err error
2025-12-04 17:49:19 +00:00
// --- 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)
// 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)
}
}
2025-12-04 12:51:08 +00:00
// Open SQLite DB
2025-12-04 17:49:19 +00:00
db, err = sql.Open("sqlite3", dbPath)
2025-12-04 12:51:08 +00:00
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 },
2025-12-04 17:49:19 +00:00
"eq": func(a, b interface{}) bool { return a == b },
2025-12-04 12:51:08 +00:00
}).Parse(indexHTML))
// Routes (HTML)
2025-12-04 12:51:08 +00:00
http.HandleFunc("/", handleIndex)
http.HandleFunc("/start", handleStartWorkout)
2025-12-04 17:49:19 +00:00
http.HandleFunc("/finish", handleFinishWorkout)
2025-12-04 12:51:08 +00:00
http.HandleFunc("/add-set", handleAddSet)
// Existing APIs
2025-12-04 12:51:08 +00:00
http.HandleFunc("/api/rest-times", handleRestTimes)
http.HandleFunc("/api/volume", handleVolumeStats)
2025-12-04 17:49:19 +00:00
http.HandleFunc("/api/last-set", handleLastSet)
2025-12-04 12:51:08 +00:00
// New JSON APIs for mobile app
http.HandleFunc("/api/workouts", handleAPIWorkouts)
http.HandleFunc("/api/workouts/", handleAPIWorkoutDetail) // /api/workouts/{id}
http.HandleFunc("/api/sets", handleAPIAddSetJSON)
2025-12-04 12:51:08 +00:00
log.Printf("Listening on %s ...", listenAddr)
if err := http.ListenAndServe(listenAddr, nil); err != nil {
log.Fatal(err)
}
}
func initDB() error {
2025-12-04 17:49:19 +00:00
// Migration-friendly: create basic tables, then ALTER for new columns.
2025-12-04 12:51:08 +00:00
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)
);
`
2025-12-04 17:49:19 +00:00
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
2025-12-04 12:51:08 +00:00
}
// --- HTML Handlers ---
2025-12-04 12:51:08 +00:00
func handleIndex(w http.ResponseWriter, r *http.Request) {
2025-12-04 17:49:19 +00:00
// Current / latest workout
latest, err := getLatestWorkout()
2025-12-04 12:51:08 +00:00
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
var sets []SetRow
2025-12-04 17:49:19 +00:00
if latest != nil {
sets, err = getSetsForWorkout(latest.ID)
2025-12-04 12:51:08 +00:00
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
}
2025-12-04 17:49:19 +00:00
// 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
}
2025-12-04 12:51:08 +00:00
data := struct {
HasWorkout bool
WorkoutID int
WorkoutActive bool
WorkoutStart *time.Time
WorkoutEnd *time.Time
2025-12-04 17:49:19 +00:00
WorkoutTemplate string
Sets []SetRow
2025-12-04 17:49:19 +00:00
From string
To string
Workouts []WorkoutSummary
2025-12-04 12:51:08 +00:00
}{
2025-12-04 17:49:19 +00:00
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,
2025-12-04 12:51:08 +00:00
}
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)
2025-12-04 17:49:19 +00:00
templateVal := r.FormValue("template") // "" / "A" / "B"
2025-12-04 12:51:08 +00:00
2025-12-04 17:49:19 +00:00
_, err := db.Exec(`INSERT INTO workouts (started_at, template) VALUES (?, ?)`, now, templateVal)
2025-12-04 12:51:08 +00:00
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
2025-12-04 17:49:19 +00:00
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)
}
2025-12-04 12:51:08 +00:00
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
2025-12-04 17:49:19 +00:00
latest, err := getLatestWorkout()
if err != nil || latest == nil {
2025-12-04 12:51:08 +00:00
http.Error(w, "no active workout", http.StatusBadRequest)
return
}
2025-12-04 17:49:19 +00:00
// exercise from dropdown + optional custom
2025-12-04 12:59:21 +00:00
selected := r.FormValue("exercise_select")
exerciseCustom := r.FormValue("exercise_custom")
exercise := selected
if selected == "custom" {
exercise = exerciseCustom
}
2025-12-04 12:51:08 +00:00
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 (?, ?, ?, ?, ?)`,
2025-12-04 17:49:19 +00:00
latest.ID, exercise, reps, weight, now,
2025-12-04 12:51:08 +00:00
)
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) {
2025-12-04 17:49:19 +00:00
latest, err := getLatestWorkout()
if err != nil || latest == nil {
2025-12-04 12:51:08 +00:00
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`,
2025-12-04 17:49:19 +00:00
latest.ID,
2025-12-04 12:51:08 +00:00
)
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()).
2025-12-04 17:49:19 +00:00
AddDate(0, 0, -(weekday-1))
2025-12-04 12:51:08 +00:00
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
}
// --- API: last set for exercise (autofill) ---
2025-12-04 17:49:19 +00:00
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)
}
// --- JSON APIs for mobile app ---
2025-12-04 12:51:08 +00:00
// /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)
}
}
2025-12-04 12:51:08 +00:00
// /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
2025-12-04 12:51:08 +00:00
}
// 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})
2025-12-04 12:51:08 +00:00
}
2025-12-04 17:49:19 +00:00
// --- Helpers & summaries ---
2025-12-04 12:51:08 +00:00
2025-12-04 17:49:19 +00:00
func getLatestWorkout() (*Workout, error) {
row := db.QueryRow(`SELECT id, started_at, ended_at, template FROM workouts ORDER BY id DESC LIMIT 1`)
2025-12-04 12:51:08 +00:00
var id int
2025-12-04 17:49:19 +00:00
var startedStr string
var endedStr sql.NullString
var templateStr sql.NullString
err := row.Scan(&id, &startedStr, &endedStr, &templateStr)
2025-12-04 12:51:08 +00:00
if err == sql.ErrNoRows {
2025-12-04 17:49:19 +00:00
return nil, nil
}
if err != nil {
return nil, err
}
started, err := time.Parse(time.RFC3339, startedStr)
if err != nil {
return nil, err
2025-12-04 12:51:08 +00:00
}
2025-12-04 17:49:19 +00:00
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
2025-12-04 12:51:08 +00:00
}
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()
}
2025-12-04 17:49:19 +00:00
// 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 (includes rest timer card) ---
2025-12-04 12:51:08 +00:00
const indexHTML = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Minimal Fitness Tracker</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2025-12-04 17:49:19 +00:00
max-width: 1100px;
2025-12-04 12:51:08 +00:00
margin: 0 auto;
padding: 1rem;
background: #111827;
color: #e5e7eb;
}
h1 { margin-bottom: 0.5rem; }
.card {
background: #1f2937;
border-radius: 0.75rem;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
label { display:block; margin-top: 0.5rem; }
input[type="text"],
input[type="number"],
2025-12-04 17:49:19 +00:00
input[type="date"],
2025-12-04 12:51:08 +00:00
select {
width: 100%;
padding: 0.4rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid #374151;
background: #111827;
color: #e5e7eb;
}
button {
margin-top: 0.75rem;
padding: 0.4rem 0.9rem;
border-radius: 0.375rem;
border: none;
background: #3b82f6;
color: white;
cursor: pointer;
}
button:hover { background: #2563eb; }
table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5rem;
}
th, td {
border-bottom: 1px solid #374151;
padding: 0.4rem 0.3rem;
text-align: left;
}
2025-12-04 17:49:19 +00:00
th { font-size: 0.80rem; text-transform: uppercase; color:#9ca3af; }
2025-12-04 12:51:08 +00:00
canvas {
max-width: 100%;
background: #111827;
border-radius: 0.75rem;
padding: 0.5rem;
}
.badge {
display:inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
background:#111827;
color:#9ca3af;
}
.row {
2025-12-04 17:49:19 +00:00
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 1rem;
}
.row-narrow {
2025-12-04 12:51:08 +00:00
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
2025-12-04 17:49:19 +00:00
@media (max-width: 900px) {
2025-12-04 12:51:08 +00:00
.row {
2025-12-04 17:49:19 +00:00
grid-template-columns: 1fr;
2025-12-04 12:51:08 +00:00
}
}
2025-12-04 12:59:21 +00:00
.flex-row {
display:flex;
gap:0.5rem;
align-items:center;
}
.flex-row input[type="text"] {
flex:1;
}
.small {
font-size:0.8rem;
color:#9ca3af;
}
2025-12-04 17:49:19 +00:00
.tag {
display:inline-block;
padding:0.1rem 0.4rem;
border-radius:999px;
font-size:0.7rem;
background:#111827;
color:#9ca3af;
cursor:pointer;
}
.tag:hover {
background:#374151;
}
.timer-display {
font-size: 1.5rem;
font-weight: 600;
margin-top: 0.5rem;
}
2025-12-04 12:51:08 +00:00
</style>
</head>
<body>
<h1>Minimal Fitness Tracker</h1>
<p style="color:#9ca3af;">Self-hosted 5×5 log A/B templates, volume, rest, history & basic API.</p>
2025-12-04 12:51:08 +00:00
<div class="row">
<div>
<div class="card">
2025-12-04 17:49:19 +00:00
<h2>Workout control</h2>
2025-12-04 12:51:08 +00:00
{{if .HasWorkout}}
2025-12-04 17:49:19 +00:00
<p>
Current workout ID:
<span class="badge">{{.WorkoutID}}</span><br>
<span class="small">
Started: {{if .WorkoutStart}}{{.WorkoutStart.Format "2006-01-02 15:04"}} UTC{{else}}-{{end}}<br>
Ended: {{if .WorkoutEnd}}{{.WorkoutEnd.Format "2006-01-02 15:04"}} UTC{{else}}(active){{end}}<br>
Template:
{{if eq .WorkoutTemplate "A"}}
<span class="tag">A: Squat / Bench / Row</span>
{{else if eq .WorkoutTemplate "B"}}
<span class="tag">B: Squat / OHP / Deadlift / Chin-up</span>
{{else}}
<span class="small">None</span>
{{end}}
</span>
</p>
2025-12-04 12:51:08 +00:00
{{else}}
<p>No workout yet.</p>
{{end}}
2025-12-04 17:49:19 +00:00
<form method="POST" action="/start" style="margin-top:0.5rem;">
<label>
Start new workout with template
<select name="template">
<option value="">None</option>
<option value="A">Workout A (Squat / Bench / Barbell Row)</option>
<option value="B">Workout B (Squat / Overhead Press / Deadlift / Chin-up)</option>
</select>
</label>
2025-12-04 12:51:08 +00:00
<button type="submit">Start new workout</button>
</form>
2025-12-04 17:49:19 +00:00
{{if .WorkoutActive}}
<form method="POST" action="/finish" style="display:inline-block;">
<button type="submit" style="background:#ef4444;">Finish workout</button>
</form>
{{end}}
2025-12-04 12:51:08 +00:00
</div>
<div class="card">
<h2>Add set</h2>
{{if .HasWorkout}}
2025-12-04 17:49:19 +00:00
{{if eq .WorkoutTemplate "A"}}
<p class="small">
Today: <span class="tag" onclick="setExercise('Squat')">Squat</span>
<span class="tag" onclick="setExercise('Bench Press')">Bench</span>
<span class="tag" onclick="setExercise('Barbell Row')">Row</span>
</p>
{{else if eq .WorkoutTemplate "B"}}
<p class="small">
Today: <span class="tag" onclick="setExercise('Squat')">Squat</span>
<span class="tag" onclick="setExercise('Overhead Press')">OHP</span>
<span class="tag" onclick="setExercise('Deadlift')">Deadlift</span>
<span class="tag" onclick="setExercise('Chin-up')">Chin-up</span>
</p>
{{end}}
2025-12-04 12:51:08 +00:00
<form method="POST" action="/add-set">
<label>
2025-12-04 17:49:19 +00:00
Exercise
<select name="exercise_select" id="exercise_select" required onchange="toggleCustomExercise(); onExerciseChange();">
2025-12-04 12:59:21 +00:00
<option value="Squat">Squat</option>
<option value="Bench Press">Bench Press</option>
<option value="Barbell Row">Barbell Row</option>
<option value="Overhead Press">Overhead Press</option>
<option value="Deadlift">Deadlift</option>
<option value="Pull-up">Pull-up</option>
<option value="Chin-up">Chin-up</option>
<option value="Dip">Dip</option>
<option value="Barbell Curl">Barbell Curl</option>
<option value="Tricep Extension">Tricep Extension</option>
<option value="Face Pull">Face Pull</option>
<option value="Lat Pulldown">Lat Pulldown</option>
<option value="custom">Custom</option>
</select>
2025-12-04 12:51:08 +00:00
</label>
2025-12-04 12:59:21 +00:00
<div class="flex-row small" id="customExerciseRow" style="margin-top:0.4rem; display:none;">
<span>Custom name:</span>
<input type="text" name="exercise_custom" id="exercise_custom" placeholder="Front Squat, Incline Bench…">
</div>
2025-12-04 12:51:08 +00:00
<label>
Reps
2025-12-04 17:49:19 +00:00
<input type="number" id="repsInput" name="reps" min="1" step="1" required>
2025-12-04 12:51:08 +00:00
</label>
<label>
Weight (kg)
2025-12-04 17:49:19 +00:00
<input type="number" id="weightInput" name="weight" step="0.5" min="0" required>
2025-12-04 12:51:08 +00:00
</label>
2025-12-04 17:49:19 +00:00
<button type="submit">Save set (end set)</button>
<p class="small">Time you press Save set = set timestamp. Rest between sets is automatic.</p>
2025-12-04 12:51:08 +00:00
</form>
{{else}}
<p style="color:#fbbf24;">Start a workout first.</p>
{{end}}
</div>
<div class="card">
<h2>Sets in latest workout</h2>
{{if .Sets}}
<table>
<thead>
<tr>
<th>#</th>
<th>Exercise</th>
<th>Reps</th>
<th>Weight</th>
<th>Time (UTC)</th>
</tr>
</thead>
<tbody>
{{range $i, $s := .Sets}}
<tr>
<td>{{add $i 1}}</td>
<td>{{$s.Exercise}}</td>
<td>{{$s.Reps}}</td>
<td>{{$s.Weight}}</td>
<td>{{$s.StartedAt.Format "15:04:05"}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No sets logged yet.</p>
{{end}}
</div>
</div>
<div>
<div class="card">
<h2>Rest time between sets (latest workout)</h2>
<canvas id="restChart" height="120"></canvas>
<p id="restInfo" style="font-size:0.85rem; color:#9ca3af; margin-top:0.3rem;"></p>
</div>
<div class="card">
<h2>Training volume (Σ weight × reps)</h2>
<label for="rangeSelect">View range</label>
<select id="rangeSelect" onchange="loadVolumeStats()">
<option value="day">Today vs yesterday</option>
<option value="week">This week vs last week</option>
<option value="month">This month vs last month</option>
</select>
<canvas id="volumeChart" height="120" style="margin-top:0.5rem;"></canvas>
<p id="volumeInfo" style="font-size:0.85rem; color:#9ca3af; margin-top:0.3rem;"></p>
</div>
<div class="card">
<h2>Rest timer (with notification)</h2>
<label>
Rest seconds
<input type="number" id="restSeconds" min="10" step="5" value="90">
</label>
<button type="button" onclick="startRestTimer()">Start rest</button>
<div id="restCountdown" class="timer-display"></div>
<p class="small">When the timer ends, youll get a browser notification (if allowed).</p>
</div>
2025-12-04 12:51:08 +00:00
</div>
</div>
2025-12-04 17:49:19 +00:00
<div class="row-narrow">
<div class="card">
<h2>Workout history & filter</h2>
<form method="GET" action="/" class="flex-row" style="gap:0.75rem; flex-wrap:wrap;">
<div style="flex:1; min-width:160px;">
<label>From
<input type="date" name="from" value="{{.From}}">
</label>
</div>
<div style="flex:1; min-width:160px;">
<label>To
<input type="date" name="to" value="{{.To}}">
</label>
</div>
<div style="align-self:flex-end;">
<button type="submit">Filter</button>
</div>
</form>
{{if .Workouts}}
<table>
<thead>
<tr>
<th>ID</th>
<th>Date</th>
<th>Start</th>
<th>End</th>
<th>Dur.</th>
<th>Sets</th>
<th>Volume</th>
<th>Rest (approx.)</th>
<th>Tpl</th>
</tr>
</thead>
<tbody>
{{range .Workouts}}
<tr>
<td>{{.ID}}</td>
<td>{{.StartedAt.Format "2006-01-02"}}</td>
<td>{{.StartedAt.Format "15:04"}}</td>
<td>{{if .HasEnded}}{{.EndedAt.Format "15:04"}}{{else}}-{{end}}</td>
<td>{{.DurationHuman}}</td>
<td>{{.SetCount}}</td>
<td>{{printf "%.0f" .Volume}}</td>
<td>{{.RestHuman}}</td>
<td>{{.Template}}</td>
</tr>
{{end}}
</tbody>
</table>
<p class="small">Rest time is estimated from time between sets (button presses).</p>
{{else}}
<p>No workouts logged yet.</p>
{{end}}
</div>
</div>
2025-12-04 12:51:08 +00:00
<!-- Chart.js from CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
let restChartInstance = null;
let volumeChartInstance = null;
let restTimerInterval = null;
let restTimerEnd = null;
2025-12-04 12:51:08 +00:00
2025-12-04 12:59:21 +00:00
function toggleCustomExercise() {
const select = document.getElementById('exercise_select');
const row = document.getElementById('customExerciseRow');
const input = document.getElementById('exercise_custom');
if (select.value === 'custom') {
row.style.display = 'flex';
input.required = true;
} else {
row.style.display = 'none';
input.required = false;
input.value = '';
}
}
2025-12-04 17:49:19 +00:00
function setExercise(name) {
const select = document.getElementById('exercise_select');
select.value = name;
toggleCustomExercise();
onExerciseChange();
}
function onExerciseChange() {
const select = document.getElementById('exercise_select');
if (!select || select.value === 'custom') return;
loadLastSetForExercise(select.value);
}
function loadLastSetForExercise(exName) {
if (!exName || exName === 'custom') return;
fetch('/api/last-set?exercise=' + encodeURIComponent(exName))
.then(res => res.json())
.then(data => {
if (!data.found) return;
const repsInput = document.getElementById('repsInput');
const weightInput = document.getElementById('weightInput');
if (repsInput && (!repsInput.value || repsInput.value === '0')) {
repsInput.value = data.reps;
}
if (weightInput && (!weightInput.value || weightInput.value === '0')) {
weightInput.value = data.weight;
}
})
.catch(() => {});
}
2025-12-04 12:51:08 +00:00
function loadRestTimes() {
fetch('/api/rest-times')
.then(res => {
if (!res.ok) {
throw new Error("No rest data yet");
}
return res.json();
})
.then(data => {
const ctx = document.getElementById('restChart').getContext('2d');
if (!data || !data.labels || data.labels.length === 0) {
document.getElementById('restInfo').textContent =
'Log at least two sets to see rest times.';
return;
}
if (restChartInstance) {
restChartInstance.destroy();
}
restChartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Rest (seconds)',
data: data.data
}]
},
options: {
scales: {
y: {
beginAtZero: true
}
}
}
});
const avg = data.data.reduce((a,b)=>a+b,0) / data.data.length;
document.getElementById('restInfo').textContent =
'Average rest: ' + avg.toFixed(1) + ' s';
})
.catch(err => {
document.getElementById('restInfo').textContent =
'No rest data yet add some sets.';
});
}
function loadVolumeStats() {
const range = document.getElementById('rangeSelect').value;
fetch('/api/volume?range=' + encodeURIComponent(range))
.then(res => {
if (!res.ok) {
throw new Error("No volume data");
}
return res.json();
})
.then(data => {
const ctx = document.getElementById('volumeChart').getContext('2d');
if (volumeChartInstance) {
volumeChartInstance.destroy();
}
volumeChartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: [data.previous_label, data.current_label],
datasets: [{
label: 'Volume (kg × reps)',
data: [data.previous_volume, data.current_volume]
}]
},
options: {
scales: {
y: {
beginAtZero: true
}
}
}
});
let text = '';
const current = data.current_volume;
const previous = data.previous_volume;
const prettyRange =
data.range === 'day' ? 'today' :
data.range === 'week' ? 'this week' :
'this month';
if (!data.has_previous) {
text = 'Total volume ' + prettyRange + ': ' + current.toFixed(1) +
' (no previous period logged).';
} else {
const change = data.percent_change;
let dir = 'no change';
let arrow = '';
if (change > 1e-6) {
dir = 'up';
arrow = '▲';
} else if (change < -1e-6) {
dir = 'down';
arrow = '▼';
}
text = 'Total volume ' + prettyRange + ': ' + current.toFixed(1) +
' (previous: ' + previous.toFixed(1) +
'). Change: ' + dir + ' ' + arrow + ' ' +
change.toFixed(1) + '%.';
}
document.getElementById('volumeInfo').textContent = text;
})
.catch(err => {
document.getElementById('volumeInfo').textContent =
'No volume data yet log some sets.';
});
}
// --- Rest timer with notification ---
function startRestTimer() {
const input = document.getElementById('restSeconds');
const display = document.getElementById('restCountdown');
let sec = parseInt(input.value, 10);
if (isNaN(sec) || sec <= 0) {
sec = 90;
input.value = 90;
}
// Ask for notification permission once
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
restTimerEnd = Date.now() + sec * 1000;
updateRestCountdown(); // immediate
if (restTimerInterval) {
clearInterval(restTimerInterval);
}
restTimerInterval = setInterval(updateRestCountdown, 250);
}
function updateRestCountdown() {
const display = document.getElementById('restCountdown');
if (!restTimerEnd) {
display.textContent = '';
return;
}
const now = Date.now();
const remainingMs = restTimerEnd - now;
if (remainingMs <= 0) {
display.textContent = "0s";
clearInterval(restTimerInterval);
restTimerInterval = null;
restTimerEnd = null;
// Fire notification if allowed
if ('Notification' in window && Notification.permission === 'granted') {
new Notification("Rest done time to lift! 💪");
}
return;
}
const sec = Math.round(remainingMs / 1000);
display.textContent = sec + "s";
}
2025-12-04 12:51:08 +00:00
document.addEventListener('DOMContentLoaded', () => {
2025-12-04 12:59:21 +00:00
toggleCustomExercise();
2025-12-04 17:49:19 +00:00
onExerciseChange();
2025-12-04 12:51:08 +00:00
loadRestTimes();
loadVolumeStats();
});
</script>
</body>
</html>
`