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"
|
2025-12-04 12:51:08 +00:00
|
|
|
|
"strconv"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
2025-12-04 17:49:19 +00:00
|
|
|
|
db *sql.DB
|
|
|
|
|
|
indexTmpl *template.Template
|
|
|
|
|
|
listenAddr = ":8080"
|
2025-12-04 12:51:08 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
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
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
// APIs
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Handlers ---
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2025-12-04 17:49:19 +00:00
|
|
|
|
HasWorkout bool
|
|
|
|
|
|
WorkoutID int
|
|
|
|
|
|
WorkoutActive bool
|
|
|
|
|
|
WorkoutStart *time.Time
|
|
|
|
|
|
WorkoutEnd *time.Time
|
|
|
|
|
|
WorkoutTemplate string
|
|
|
|
|
|
Sets []SetRow
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-04 17:49:19 +00:00
|
|
|
|
// --- 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-04 12:51:08 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-04 12:51:08 +00:00
|
|
|
|
// --- HTML template (single page) ---
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2025-12-04 12:51:08 +00:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<h1>Minimal Fitness Tracker</h1>
|
2025-12-04 17:49:19 +00:00
|
|
|
|
<p style="color:#9ca3af;">Self-hosted 5×5 log – A/B templates, volume, rest, and history.</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">Tip: the time you press “Save set” is used as the set time. Rest between sets is calculated automatically.</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>
|
|
|
|
|
|
</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;
|
|
|
|
|
|
|
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.';
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
`
|