Fitnes-tracker/main.go

1533 lines
38 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// main.go
package main
import (
"database/sql"
"encoding/json"
"html/template"
"log"
"net/http"
"os"
"path"
"strconv"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
var (
db *sql.DB
indexTmpl *template.Template
listenAddr = ":8080"
)
// --- Types ---
// 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
}
// 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
// --- 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)
}
}
// 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 (HTML)
http.HandleFunc("/", handleIndex)
http.HandleFunc("/start", handleStartWorkout)
http.HandleFunc("/finish", handleFinishWorkout)
http.HandleFunc("/add-set", handleAddSet)
// 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)
}
}
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
}
// --- HTML 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)
}
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")
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 ---
// /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)
}
}
// /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 ---
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 (includes rest timer card) ---
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;
max-width: 1100px;
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"],
input[type="date"],
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;
}
th { font-size: 0.80rem; text-transform: uppercase; color:#9ca3af; }
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 {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 1rem;
}
.row-narrow {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (max-width: 900px) {
.row {
grid-template-columns: 1fr;
}
}
.flex-row {
display:flex;
gap:0.5rem;
align-items:center;
}
.flex-row input[type="text"] {
flex:1;
}
.small {
font-size:0.8rem;
color:#9ca3af;
}
.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;
}
</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>
<div class="row">
<div>
<div class="card">
<h2>Workout control</h2>
{{if .HasWorkout}}
<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>
{{else}}
<p>No workout yet.</p>
{{end}}
<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>
<button type="submit">Start new workout</button>
</form>
{{if .WorkoutActive}}
<form method="POST" action="/finish" style="display:inline-block;">
<button type="submit" style="background:#ef4444;">Finish workout</button>
</form>
{{end}}
</div>
<div class="card">
<h2>Add set</h2>
{{if .HasWorkout}}
{{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}}
<form method="POST" action="/add-set">
<label>
Exercise
<select name="exercise_select" id="exercise_select" required onchange="toggleCustomExercise(); onExerciseChange();">
<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>
</label>
<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>
<label>
Reps
<input type="number" id="repsInput" name="reps" min="1" step="1" required>
</label>
<label>
Weight (kg)
<input type="number" id="weightInput" name="weight" step="0.5" min="0" required>
</label>
<button type="submit">Save set (end set)</button>
<p class="small">Time you press “Save set” = set timestamp. Rest between sets is automatic.</p>
</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>
</div>
</div>
<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>
<!-- 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;
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 = '';
}
}
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(() => {});
}
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";
}
document.addEventListener('DOMContentLoaded', () => {
toggleCustomExercise();
onExerciseChange();
loadRestTimes();
loadVolumeStats();
});
</script>
</body>
</html>
`